Merge branch 'develop' into 16149-script-list

This commit is contained in:
Arthur 2024-06-10 11:18:17 -07:00
commit 48bd8d1347
100 changed files with 38337 additions and 33095 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -30,4 +30,3 @@ jobs:
This is a reminder that additional information is needed in order to further 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 triage this issue. If the requested details are not provided, the issue will
soon be closed automatically. soon be closed automatically.
start-date: 2024-05-14

View File

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

View 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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
![Transifex download](../media/development/transifex_download.png)
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

View File

@ -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.
![Transifex manual sync](../media/development/transifex_sync.png)
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.
![Transifex pull request](../media/development/transifex_pull_request.png)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

View File

@ -1,22 +1,66 @@
# NetBox v4.0 # NetBox v4.0
## v4.0.3 (FUTURE) ## v4.0.6 (FUTURE)
---
## v4.0.5 (2024-06-06)
### 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 ### Enhancements
* [#12984](https://github.com/netbox-community/netbox/issues/12984) - Add Molex Micro-Fit power port & outlet types * [#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 * [#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 * [#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 ### Bug Fixes
* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM * [#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 * [#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 * [#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 * [#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
--- ---

View File

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

View File

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

View File

@ -224,7 +224,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)
)) ))

View File

@ -828,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'
@ -892,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'
@ -939,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'
@ -982,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)'),
@ -1060,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'),
) )
), ),
( (
@ -1128,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)'),
) )
), ),
( (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,7 +58,13 @@ 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
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else:
queue[key] = {
'content_type': ContentType.objects.get_for_model(instance), 'content_type': ContentType.objects.get_for_model(instance),
'object_id': instance.pk, 'object_id': instance.pk,
'event': action, 'event': action,
@ -66,7 +72,7 @@ def enqueue_object(queue, instance, user, request_id, action):
'snapshots': get_snapshots(instance, action), 'snapshots': get_snapshots(instance, action),
'username': user.username, 'username': user.username,
'request_id': request_id '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))

View File

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

View File

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

View File

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

View File

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

View File

@ -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,13 +100,12 @@ 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):
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) enqueue_object(queue, instance, request.user, request.id, action)
events_queue.set(queue) events_queue.set(queue)
@ -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({})
# #

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
@ -405,6 +406,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
# #
@ -643,6 +649,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
# #
@ -726,6 +737,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
# #
@ -893,6 +909,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
# #
@ -1259,3 +1280,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()

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -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(
'click',
async () => {
const search = new Event('search'); const search = new Event('search');
quicksearch.value = ''; quicksearch.value = '';
await new Promise(f => setTimeout(f, 100)); await new Promise(f => setTimeout(f, 100));
quicksearch.dispatchEvent(search); quicksearch.dispatchEvent(search);
}, { clearLinkParams();
passive: true },
}) {
passive: true,
},
);
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,10 @@
from typing import List from typing import List
import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from strawberry import auto from netbox.graphql.types import BaseObjectType
from users import filtersets
from users.models import Group from users.models import Group
from utilities.querysets import RestrictedQuerySet
from .filters import * from .filters import *
__all__ = ( __all__ = (
@ -21,17 +18,16 @@ __all__ = (
fields=['id', 'name'], fields=['id', 'name'],
filters=GroupFilter filters=GroupFilter
) )
class GroupType: class GroupType(BaseObjectType):
pass pass
@strawberry_django.type( @strawberry_django.type(
get_user_model(), get_user_model(),
fields=[ fields=[
'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups',
'is_active', 'date_joined', 'groups',
], ],
filters=UserFilter filters=UserFilter
) )
class UserType: class UserType(BaseObjectType):
groups: List[GroupType] groups: List[GroupType]

View File

@ -87,7 +87,7 @@ def get_paginate_count(request):
pass pass
if request.user.is_authenticated: if request.user.is_authenticated:
per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT) per_page = request.user.config.get('pagination.per_page') or config.PAGINATE_COUNT
return _max_allowed(per_page) return _max_allowed(per_page)
return _max_allowed(config.PAGINATE_COUNT) return _max_allowed(config.PAGINATE_COUNT)

View File

@ -2,7 +2,6 @@ import json
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers from django.core import serializers
from mptt.models import MPTTModel
from extras.utils import is_taggable from extras.utils import is_taggable
@ -16,8 +15,7 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
""" """
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are can be provided to exclude them from the returned dictionary.
implicitly excluded.
Args: Args:
obj: The object to serialize obj: The object to serialize
@ -30,11 +28,6 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
data = json.loads(json_str)[0]['fields'] data = json.loads(json_str)[0]['fields']
exclude = exclude or [] exclude = exclude or []
# Exclude any MPTTModel fields
if issubclass(obj.__class__, MPTTModel):
for field in ['level', 'lft', 'rght', 'tree_id']:
data.pop(field)
# Include custom_field_data as "custom_fields" # Include custom_field_data as "custom_fields"
if hasattr(obj, 'custom_field_data'): if hasattr(obj, 'custom_field_data'):
data['custom_fields'] = data.pop('custom_field_data') data['custom_fields'] = data.pop('custom_field_data')
@ -45,9 +38,9 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
tags = getattr(obj, '_tags', None) or obj.tags.all() tags = getattr(obj, '_tags', None) or obj.tags.all()
data['tags'] = sorted([tag.name for tag in tags]) data['tags'] = sorted([tag.name for tag in tags])
# Skip excluded and private (prefixes with an underscore) attributes # Skip any excluded attributes
for key in list(data.keys()): for key in list(data.keys()):
if key in exclude or (isinstance(key, str) and key.startswith('_')): if key in exclude:
data.pop(key) data.pop(key)
# Append any extra data # Append any extra data

View File

@ -1,5 +1,5 @@
<div class="htmx-container table-responsive" <div class="htmx-container table-responsive"
hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" hx-get="{% url viewname %}?embedded=True{% if url_params %}&{{ url_params.urlencode }}{% endif %}"
hx-target="this" hx-target="this"
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML" hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div> ></div>

View File

@ -4,7 +4,7 @@
<i class="mdi mdi-download"></i> {% trans "Export" %} <i class="mdi mdi-download"></i> {% trans "Export" %}
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li> <li><a id="export_current_view" class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li>
<li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li> <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
{% if export_templates %} {% if export_templates %}
<li> <li>

View File

@ -1,14 +1,9 @@
import datetime
import json import json
from typing import Dict, Any from typing import Dict, Any
from urllib.parse import quote from urllib.parse import quote
from django import template from django import template
from django.conf import settings
from django.template.defaultfilters import date
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from django.utils.safestring import mark_safe
from core.models import ObjectType from core.models import ObjectType
from utilities.forms import get_selected_values, TableConfigForm from utilities.forms import get_selected_values, TableConfigForm
@ -92,15 +87,22 @@ def humanize_speed(speed):
@register.filter() @register.filter()
def humanize_megabytes(mb): def humanize_megabytes(mb):
""" """
Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes). Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.).
""" """
if not mb: if not mb:
return '' return ""
if not mb % 1048576: # 1024^2
return f'{int(mb / 1048576)} TB' PB_SIZE = 1000000000
if not mb % 1024: TB_SIZE = 1000000
return f'{int(mb / 1024)} GB' GB_SIZE = 1000
return f'{mb} MB'
if mb >= PB_SIZE:
return f"{mb / PB_SIZE:.2f} PB"
if mb >= TB_SIZE:
return f"{mb / TB_SIZE:.2f} TB"
if mb >= GB_SIZE:
return f"{mb / GB_SIZE:.2f} GB"
return f"{mb} MB"
@register.filter() @register.filter()

View File

@ -493,10 +493,18 @@ class APIViewTestCases:
def _build_filtered_query(self, name, **filters): def _build_filtered_query(self, name, **filters):
""" """
Create a filtered query: i.e. ip_address_list(filters: {address: "1.1.1.1/24"}){. Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
""" """
# TODO: This should be extended to support AND, OR multi-lookups
if filters: if filters:
filter_string = ', '.join(f'{k}: "{v}"' for k, v in filters.items()) for field_name, params in filters.items():
lookup = params['lookup']
value = params['value']
if lookup:
query = f'{{{lookup}: "{value}"}}'
filter_string = f'{field_name}: {query}'
else:
filter_string = f'{field_name}: "{value}"'
filter_string = f'(filters: {{{filter_string}}})' filter_string = f'(filters: {{{filter_string}}})'
else: else:
filter_string = '' filter_string = ''

View File

@ -173,6 +173,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
row_attrs = { row_attrs = {
'data-name': lambda record: record.name, 'data-name': lambda record: record.name,
'data-virtual': lambda record: "true",
'data-enabled': lambda record: "true" if record.enabled else "false",
} }

View File

@ -15,23 +15,23 @@ django-tables2==2.7.0
django-timezone-field==6.1.0 django-timezone-field==6.1.0
djangorestframework==3.15.1 djangorestframework==3.15.1
drf-spectacular==0.27.2 drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.5.1 drf-spectacular-sidecar==2024.6.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==22.0.0 gunicorn==22.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.22 mkdocs-material==9.5.26
mkdocstrings[python-legacy]==0.25.1 mkdocstrings[python-legacy]==0.25.1
netaddr==1.2.1 netaddr==1.3.0
nh3==0.2.17 nh3==0.2.17
Pillow==10.3.0 Pillow==10.3.0
psycopg[c,pool]==3.1.19 psycopg[c,pool]==3.1.19
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.32.3
social-auth-app-django==5.4.1 social-auth-app-django==5.4.1
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.229.0 strawberry-graphql==0.234.0
strawberry-graphql-django==0.40.0 strawberry-graphql-django==0.42.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.6.1 tablib==3.6.1
tzdata==2024.1 tzdata==2024.1