Compare commits

...

105 Commits

Author SHA1 Message Date
Jeremy Stretch
e68b83907b Merge pull request #16432 from netbox-community/develop
Release v4.0.5
2024-06-06 11:59:00 -04:00
Jeremy Stretch
2682f03a6b Re-bundle static assets 2024-06-06 11:42:47 -04:00
Jeremy Stretch
2304df84d5 Merge branch 'master' into develop 2024-06-06 11:36:08 -04:00
Jeremy Stretch
5530556626 Merge pull request #16429 from netbox-community/develop
Release v4.0.5
2024-06-06 11:31:54 -04:00
Jeremy Stretch
e4d240ace2 Release v4.0.5 2024-06-06 10:55:30 -04:00
transifex-integration[bot]
58f22eec37 Updates for project NetBox (#16346)
* Translate django.po in de [Manual Sync]

74% of minimum 1% reviewed source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in de [Manual Sync]

74% of minimum 1% reviewed source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in ru [Manual Sync]

30% of minimum 1% reviewed source file: 'django.po'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in pt [Manual Sync]

2% of minimum 1% reviewed source file: 'django.po'
on 'pt'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in fr [Manual Sync]

12% of minimum 1% reviewed source file: 'django.po'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in tr [Manual Sync]

7% of minimum 1% reviewed source file: 'django.po'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-06-06 10:27:06 -04:00
Julio Oliveira at Encora
7e1b3d0b54 15873 - Make Cluster resource counters more readable (#15900)
* Created "convert_byte_size" method to convert the memory and disk size according to unit informed.
Changed "get_extra_context" method from "ClusterView" to use the method above and convert all the disks and memories from VMs to normalize the units.

* Changed decimal size for memory_sum and disk_sum

* Added test for convert_byte_size.

* Fixed

* Addressed PR comments.
Changed humanize_megabytes in helpers.py

* Addressed PR comments.
Changed humanize_megabytes in helpers.py

* Linter issues for helpers.py

* Changed humanize_megabytes

* Changed humanize_megabytes

* Changed humanize_megabytes

* Added the title to display the value in MB when mouseover.

* Addressed PR comment.

* Addressed PR comment.

* Rewrite sizing logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-06 09:37:29 -04:00
Julio Oliveira at Encora
3acf3b51ee Fixes: #14567 - Export current view of IP Addresses (#15659)
* Added javascript and htmx to change the url.

* Added javascript and htmx to change the url

* Addressed PR comments

* Added Netbox.js and netbox.js.map

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Linter Issues

* Fix assets issue

* Fix assets issue

* Addressed PR comment.
It was added clearLinkParams to clear button.

* Added passive:true to search.ts

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-06 09:35:27 -04:00
Arthur Hanson
8f87c72eaa 16050 Show script python_class name and description (#16185)
* 16050 Show script python_class name and description

* 16050 change to use Meta.description

* 16050 change to use Meta.description

* 16050 remove module name customization from docs
2024-06-06 09:05:59 -04:00
Louis Jarasius
18b43408ec Fixes #16274: Dark mode highlight color (#16355)
* Increase ::selection background-color aplha

* Improve comment for override

* Add compiled CSS

* Only override on dark theme
2024-06-06 08:44:32 -04:00
Julio Oliveira at Encora
b10fb67ce9 Fixed error when the active Config is deleted and rest only one to restore. (#16408) 2024-06-05 12:23:36 -07:00
Jeremy Stretch
c27cb6f153 Fix styling of object jobs table 2024-06-05 09:02:05 -04:00
github-actions
81f0a40505 Update source translation strings 2024-06-05 05:02:18 +00:00
Jeremy Stretch
4242546270 Fixes #16376: Log changes on terminating objects when attaching a cable 2024-06-04 14:37:33 -04:00
Julio Oliveira at Encora
87109f5539 16315 - Cant filter changelog by object type (no results found) (#16324)
* Replaced "api=/api/extras/content-types/" with "/api/extras/object-types/" for JournalEntryFilterForm and ObjectChangeFilterForm.

* Addressed PR comment.

* Correct feature classifications

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 09:37:08 -04:00
Daniel Sheppard
8ab9afb8db Fixes: #16083 - Add font-variant-ligatures setting to disable ligatur… (#16383)
* Fixes: #16083 - Add font-variant-ligatures setting to disable ligatures on chromium

* Fix comment

* Disable ligatures on input fields

* Condense rules & apply to all elements

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 09:02:38 -04:00
Jamie (Bear) Murphy
7be003f5a0 Allow plugins to extend objectchange view (#16371)
* allow plugins to extend objectchangeview with panels

* replace tabs with spaces

* Update netbox/templates/extras/objectchange.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Eliminate excessive vertical margin

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 08:49:08 -04:00
github-actions
291e0665d0 Update source translation strings 2024-06-04 05:02:13 +00:00
Arthur Hanson
8e48e939aa 16261 fix graphql lookup for MultiValueCharFilter fields (#16354)
* 16261 fix graphql lookup for MultiValueCharFilter fields

* 16261 fix graphql lookup for MultiValueCharFilter fields

* 16261 fixup test

* 16261 fixup test

* Omit redundant assignment

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-03 10:24:01 -04:00
Daniel Sheppard
fdad59c8cc Fixes: #16039 - Fix row highlighting on device components and VM interfaces (#16044)
* Fix row highlighting

* Minor fix for VMInterfaces

* Move duplicated dicts into inheritable meta class

* Add CableTerminationTable.Meta class for inheritance of the row_attrs to each descendant Meta class.
2024-06-03 08:47:53 -04:00
Jeremy Stretch
24d02cb381 Fixes #15194: Prevent enqueuing duplicate events for an object 2024-06-03 08:34:26 -04:00
Jeremy Stretch
602754439a Update workflows to use most recent release of each action 2024-06-03 08:01:50 -04:00
github-actions
e18e6cf756 Update source translation strings 2024-06-01 05:02:24 +00:00
Jeremy Stretch
0dde0b506e Fixes #16312: Fix object list navigation for dashboard widgets 2024-05-31 13:16:41 -04:00
Jeremy Stretch
26a856f57c Changelog for #13422, #14810, #15489, #16202, #16286, #16290 2024-05-31 10:29:53 -04:00
Jeremy Stretch
e095ec6860 Fixes #13422: Rebuild MPTT trees for applicable models when merging staged changes 2024-05-31 10:07:07 -04:00
Jeremy Stretch
05c69f84e6 Enable scheduled runs 2024-05-30 10:43:54 -04:00
github-actions
05d3224c33 Update source translation strings 2024-05-30 14:23:18 +00:00
Jeremy Stretch
4ad74587e5 Fix action permissions 2024-05-30 10:21:02 -04:00
Jeremy Stretch
153341c1b7 Install gettext 2024-05-30 10:14:43 -04:00
Jeremy Stretch
f5aa34bb37 Add GitHub action to run makemessages 2024-05-30 09:56:56 -04:00
Jeremy Stretch
a3c4984623 Skip CI if changes are limited to non-code paths 2024-05-30 08:37:24 -04:00
Jeremy Stretch
67165a9f91 Remove abhi1693 from issue triage rotation 2024-05-29 11:37:25 -04:00
Arthur Hanson
4d924a9041 16202 fix mapit button for internationalized decimal seperator (#16270)
* 16202 fix mapit button for internationalized decimal seperator

* 16202 revert untranslate

* 16202 revert untranslate
2024-05-29 10:22:59 -04:00
Jeremy Stretch
a094719d23 Closes #16290: Capture entire object in changelog data 2024-05-29 09:34:22 -04:00
Jeremy Stretch
418389c577 Update translations workflow documentation 2024-05-29 09:14:02 -04:00
Markku Leiniö
f1bf4c8758 Closes #16297: Add uwsgi.ini in .gitignore 2024-05-28 12:12:33 -04:00
Arthur
0bfb9777be 14810 add contacts to service 2024-05-28 09:44:41 -04:00
Arthur
360f3bc01b 16284 fix plugin forms doc 2024-05-28 09:07:32 -04:00
Arthur
8a91252d51 16286 fix provider account search 2024-05-28 09:06:34 -04:00
Julio-Oliveira-Encora
eb3adc050d Added 1000-Base-TX to the choices.py 2024-05-28 09:01:15 -04:00
Jeremy Stretch
103c08c2d2 Update exempt issue labels for stale action 2024-05-22 15:39:24 -04:00
Jeremy Stretch
806ff646e2 PRVB 2024-05-22 14:57:39 -04:00
Jeremy Stretch
3f345cdbee Merge pull request #16247 from netbox-community/develop
Release v4.0.3
2024-05-22 14:56:03 -04:00
Jeremy Stretch
99b8f589cf Release v4.0.3 2024-05-22 14:10:00 -04:00
transifex-integration[bot]
ec510d865f Updates for file netbox/translations/en/LC_MESSAGES/django.po (#16243)
* Translate django.po in es

100% translated source file: 'django.po'
on 'es'.

* Translate django.po in pt

100% translated source file: 'django.po'
on 'pt'.

* Translate django.po in ja

100% translated source file: 'django.po'
on 'ja'.

* Translate django.po in de

100% translated source file: 'django.po'
on 'de'.

* Translate django.po in uk

100% translated source file: 'django.po'
on 'uk'.

* Translate django.po in ru

100% translated source file: 'django.po'
on 'ru'.

* Translate django.po in fr

100% translated source file: 'django.po'
on 'fr'.

* Translate django.po in tr

100% translated source file: 'django.po'
on 'tr'.

* Translate django.po in zh

100% translated source file: 'django.po'
on 'zh'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-05-22 13:54:49 -04:00
Jeremy Stretch
cd3dea7ca9 Update origin strings for translation 2024-05-22 13:42:22 -04:00
Arthur Hanson
753c4021eb 14948 add has_virtual_device_contexts filter to device (#16209)
* 14948 add has_virtual_device_cnotexts filter to device

* 14948 make singular

* 14948 add test
2024-05-22 11:51:15 -04:00
Arthur Hanson
8e4466812d 16145 Use module.ScriptName to call Script API instead of PK (#16170)
* 16145 script api use module.script name instead of pk

* 16145 fix test

* 16145 allow both pk and script name

* 16145 update doc string

* Simplify retrieval logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-22 10:42:36 -04:00
Jeremy Stretch
83d3de276b Fixes #16232: Fix inclusion of bulk action checkboxes on dynamic tables 2024-05-22 10:35:19 -04:00
Jeremy Stretch
97f8f94ebb Changelog for #13764, #14653, #15082, #15603, #15962, #16164, #16173, #16228 2024-05-21 16:53:17 -04:00
Rémi NICOLE
60f5dd7b51 Support Redis Unix sockets (#16227)
* Fixes #15962: support Redis Unix sockets

* Clean up language & remove obsolete note

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-21 16:51:28 -04:00
Arthur Hanson
5b83d7040f 14653 Add Inventory Item column to all Device components tables (#16210)
* 14653 Add Inventory Item column to all Device components tables

* 14653 add inventory_items to base class
2024-05-21 16:40:35 -04:00
Jeremy Stretch
a3b34c7a78 Fixes #16228: Fix permissions enforcement for GraphQL queries of users & groups 2024-05-21 16:38:37 -04:00
Jeremy Stretch
902c61bf47 Rename environment variable controlling public docs build 2024-05-21 15:22:40 -04:00
Jeremy Stretch
09c1228712 Fixes #16216: Fix validation of JournalEntry when referenced by a custom field 2024-05-21 10:59:10 -04:00
Jeremy Stretch
02755d43d5 Define separate stale & close timers for PRs 2024-05-21 10:52:23 -04:00
Jeremy Stretch
44771d1221 Fixes #16139: Ensure input buttons use standard font family 2024-05-21 10:25:34 -04:00
Arthur Hanson
88461f9d7a 14250 add BPON to interface types (#16208)
* 14250 add BPON to interface types

* 14250 remove huwai specific from PON

* Reorder choices & fix typo

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-21 10:08:54 -04:00
Julio Oliveira at Encora
ade6d2e11b 16117 - Allow filtering by VLAN in Prefixes (#16204)
* Updated clean method on DynamicModelMultipleChoiceField to return the name.

* Updated VLAN section name
2024-05-21 10:07:58 -04:00
Julio Oliveira at Encora
b0520b9e60 Fixes #15603 - Added 5G to Cellular choices in dcim/choices.py. (#15677)
* Added 5G to Cellular choices in dcim/choices.py.

* Added 4G for Cellular choices.
2024-05-21 10:02:09 -04:00
Julio-Oliveira-Encora
85ca750ad7 Changed "clean_extra_choices" in "CustomFieldChoiceSetForm" to strip the space for value and label. 2024-05-21 09:59:24 -04:00
Arthur
17799df72e 13764 Add contacts to IP views 2024-05-21 09:06:49 -04:00
Jeremy Stretch
233b9029e1 Remove start date restriction from stale check for incomplete issues 2024-05-20 11:36:35 -04:00
devon-mar
5e92dac4ac Fix pagination when pagination.per_page is "" 2024-05-20 10:29:24 -04:00
Julio-Oliveira-Encora
6c51b89502 Updated clean method on DynamicModelMultipleChoiceField to return the name. 2024-05-20 08:37:31 -04:00
Jeremy Stretch
558a9beda2 Changelog for #12984, #13293, #14953, #14982, #15353, #15496, #16138 2024-05-17 16:23:02 -04:00
arcticash
9751ce6cb3 Moving the Molex connectors into their own category for better UX - expansion on #12984 2024-05-17 16:18:32 -04:00
arcticash
270a1da601 Adding Molex Micro-Fit connectors to power outlet choices to fix #12984 2024-05-17 16:18:32 -04:00
arcticash
46d12fbe2e Adding Molex Micro-Fit connectors to power plug choices to fix #12984 2024-05-17 16:18:32 -04:00
Sami Tahri
79b9ef7d0c fix: SerializedPKRelatedField schema now use nested serializer or response 2024-05-17 16:16:21 -04:00
Arthur Hanson
97a37576fc 14953 fix serializers when using add_related_count (#16158)
* 14953 fix serializers when using add_related_count

* 14953 update comments

* Set default=0 for annotated count fields

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 15:50:39 -04:00
Arthur Hanson
b2d2a23c26 15496 Add circuit termination to menu and associated forms (#15980)
* 15496 base changes

* 15496 detail view template

* 15496 tweaks

* 15496 bulk views

* 15496 filterset

* 15496 optimize qs

* 15496 bulk edit

* 15496 bulk import

* 15496 update tests

* Update netbox/templates/circuits/circuittermination.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* 15496 review changes

* 15496 template include

* 15496 expand filters

* 15496 split import form

* 15496 split import form

* 15496 add test for circuit bulk import with termiantions

* Add test for provider filters

* Rename provider column

* Fix test

* Misc cleanup

* Fix test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 15:30:10 -04:00
Arthur Hanson
d060b380c9 16138 fix user/group permissions (#16152)
* 16138 change view perms

* 16138 add migration of group perms

* 16138 update users and groups in perm selection
2024-05-17 15:07:19 -04:00
Arthur Hanson
58da5c1252 15353 add better script error message (#15441)
* 15353 add better script error message

* Simplify _get_script_class() & add docstring

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 14:54:30 -04:00
Jeremy Stretch
4b2f26a800 Correct label name 2024-05-16 14:08:28 -04:00
Jeremy Stretch
cfe010007f Enable stale bot for incomplete issues 2024-05-16 14:04:37 -04:00
Jeremy Stretch
755513a148 #16127: Ignore local_settings.py 2024-05-15 17:05:39 -04:00
Jeremy Stretch
d78a86afac Drop Repography stats from README (malfunctioning) 2024-05-15 17:02:01 -04:00
Jeremy Stretch
dba36fafa7 Enable translation support for Chinese, German, and Ukrainian 2024-05-15 16:36:30 -04:00
transifex-integration[bot]
b666b10f14 Updates for file netbox/translations/en/LC_MESSAGES/django.po (#16151)
* Translate django.po in ja

100% translated source file: 'django.po'
on 'ja'.

* Translate django.po in uk

100% translated source file: 'django.po'
on 'uk'.

* Translate django.po in de

100% translated source file: 'django.po'
on 'de'.

* Translate django.po in zh

100% translated source file: 'django.po'
on 'zh'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-05-15 16:28:52 -04:00
Jeremy Stretch
0b7804c01c Fixes #13293: Limit interface selector for IP address to current device/VM 2024-05-14 14:48:47 -04:00
Jeremy Stretch
69545fd82d PRVB 2024-05-14 11:26:19 -04:00
Jeremy Stretch
cca1b0a897 Merge pull request #16132 from netbox-community/develop
Release v4.0.2
2024-05-14 11:20:56 -04:00
Jeremy Stretch
70c0aec53a Release v4.0.2 2024-05-14 11:02:17 -04:00
Jeremy Stretch
beb9b96395 Changelog for #16096, #16107, #16123, #16124, #16127 2024-05-14 10:35:00 -04:00
Jeremy Stretch
e5ab48e3c5 Fixes #16123: Fix custom script execution via REST API 2024-05-14 10:31:55 -04:00
Jeremy Stretch
c95dd0b4d1 Update translations 2024-05-14 09:30:28 -04:00
Jeremy Stretch
34f8bf7caf Update source strings for translations 2024-05-14 09:22:27 -04:00
Anton
1feb3742e2 add ENABLE_TRANSLATION setting to optionally turn translation off (#16096)
* add USE_I18N setting

* change setting name to ENABLE_TRANSLATION

* raise a warning in the UI when translation is disabled

* Misc cleanup

* Rename to TRANSLATION_ENABLED for consistency with other settings

---------

Co-authored-by: Anton Myasnikov <anton.myasnikov@nordigy.ru>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-14 09:21:00 -04:00
Jeremy Stretch
829bae6b29 Fixes #16124: Fix GraphQL API support for querying virtual machine interfaces 2024-05-14 09:15:57 -04:00
Jeremy Stretch
fcc8eccb6c Closes #16127: Enable loading local settings 2024-05-14 09:14:40 -04:00
Jeremy Stretch
c117218332 Fix permissions for stalebot (see actions/stale #1131) 2024-05-14 08:20:31 -04:00
Jeremy Stretch
b8a8db09ed Closes #16107: Set LOGIN_REQUIRED to True by default (#16122)
* Closes #16107: Set LOGIN_REQUIRED to True by default

* Update tests
2024-05-14 07:53:19 -04:00
Jeremy Stretch
b67eda403a Changelog for #15119, #16077, #16078, #16090, #16101 2024-05-13 19:15:40 -04:00
Arthur Hanson
b291aa4312 16078 make GraphQL NumberFilter optional (#16115)
* 16078 make GraphQL NumberFilter optional

* 16078 add tests for graphql filtering

* 16078 add tests for graphql filtering

* 16078 add tests for graphql filtering
2024-05-13 19:01:30 -04:00
Jeremy Stretch
e6ccea0168 Refactor & expand search view tests 2024-05-13 18:56:44 -04:00
Jeremy Stretch
a20ccfee7e Update queryset resolution methods for compatibility with Django 5.0 2024-05-13 18:56:44 -04:00
Jeremy Stretch
c7850b586b Fixes #16101: Fix initial loading of pagination widget for dynamic object tables 2024-05-13 18:55:13 -04:00
Jeremy Stretch
e0f138dea2 Closes #16070: Set default template for ObjectChildrenView 2024-05-13 15:21:52 -04:00
Arthur
5be14b0ee2 16110 fix typo 2024-05-13 15:20:33 -04:00
Julio-Oliveira-Encora
dffd52d6b0 Added Cluster category and cluster, cluster_group for VLAN Group filters. 2024-05-13 15:16:01 -04:00
Markku Leiniö
4b91e79d1e Closes #16090: Show NetBox version if plugin validation fails (#16094)
* Closes #16090: Show NetBox version if plugin validation fails

* Shorten error message

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-13 09:37:40 -04:00
Arthur
111cbe5b7c 16077 fix display of config revision 2024-05-13 09:33:30 -04:00
Jeremy Stretch
4a64a3f6e0 PRVB 2024-05-09 16:03:13 -04:00
148 changed files with 76200 additions and 26934 deletions

View File

@@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.1
placeholder: v4.0.5
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.0.1
placeholder: v4.0.5
validations:
required: true
- type: dropdown

View File

@@ -12,10 +12,10 @@ jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- uses: pozil/auto-assign-issue@v1
- uses: pozil/auto-assign-issue@v2
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with:
# Weighted assignments
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
numOfAssignee: 1
abortIfPreviousAssignees: true

View File

@@ -1,7 +1,18 @@
name: CI
on: [push, pull_request]
on:
push:
paths-ignore:
- 'contrib/**'
- 'docs/**'
pull_request:
paths-ignore:
- 'contrib/**'
- 'docs/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -34,12 +45,12 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
@@ -47,7 +58,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: yarn

View File

@@ -0,0 +1,32 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: Close incomplete issues
on:
schedule:
- cron: '15 4 * * *'
workflow_dispatch:
permissions:
actions: write
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
close-issue-message: >
This issue is being closed as no further information has been provided. If
you would like to revisit this topic, please first modify your original post
to include all the requested detail, and then ask that the issue be reopened.
days-before-stale: 7
days-before-close: 7
only-issue-labels: 'status: revisions needed'
operations-per-run: 100
remove-stale-when-updated: false
stale-issue-label: 'pending closure'
stale-issue-message: >
This is a reminder that additional information is needed in order to further
triage this issue. If the requested details are not provided, the issue will
soon be closed automatically.

View File

@@ -7,6 +7,7 @@ on:
workflow_dispatch:
permissions:
actions: write
issues: write
pull-requests: write
@@ -16,18 +17,19 @@ jobs:
steps:
- uses: actions/stale@v9
with:
# General parameters
operations-per-run: 100
remove-stale-when-updated: false
# Issue parameters
close-issue-message: >
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
core maintainers may elect to reopen this issue at a later date if deemed
necessary.
close-pr-message: >
This PR has been automatically closed due to lack of activity.
days-before-stale: 90
days-before-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100
remove-stale-when-updated: false
days-before-issue-stale: 90
days-before-issue-close: 30
exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
stale-issue-label: 'pending closure'
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
@@ -37,6 +39,12 @@ jobs:
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
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-message: >
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'

2
.gitignore vendored
View File

@@ -17,9 +17,11 @@ yarn-error.log*
/venv/
/*.sh
local_requirements.txt
local_settings.py
!upgrade.sh
fabfile.py
gunicorn.py
uwsgi.ini
netbox.log
netbox.pid
.DS_Store

View File

@@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-10-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p>
</div>
@@ -95,16 +95,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
* [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself!
## Project Stats
<p align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</p>
## Screenshots
<p align="center">

View File

@@ -131,7 +131,7 @@ social-auth-app-django
strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# SVG image rendering (used for rack elevations)

View File

@@ -179,6 +179,9 @@
"usb-micro-ab",
"usb-3-b",
"usb-3-micro-b",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal",
"saf-d-grid",
"neutrik-powercon-20",
@@ -281,6 +284,9 @@
"usb-a",
"usb-micro-b",
"usb-c",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal",
"hdot-cx",
"saf-d-grid",
@@ -317,6 +323,7 @@
"100base-tx",
"100base-t1",
"1000base-t",
"1000base-tx",
"2.5gbase-t",
"5gbase-t",
"10gbase-t",
@@ -375,6 +382,8 @@
"gsm",
"cdma",
"lte",
"4g",
"5g",
"sonet-oc3",
"sonet-oc12",
"sonet-oc48",
@@ -408,12 +417,15 @@
"e3",
"xdsl",
"docsis",
"bpon",
"epon",
"10g-epon",
"gpon",
"xg-pon",
"xgs-pon",
"ng-pon2",
"epon",
"10g-epon",
"25g-pon",
"50g-pon",
"cisco-stackwise",
"cisco-stackwise-plus",
"cisco-flexstack",

View File

@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #}
{% if not config.extra.readthedocs %}
{# Disable search indexing unless we're building for public consumption #}
{% if not config.extra.build_public %}
<meta name="robots" content="noindex">
{% endif %}
{% 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
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.
### 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
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal

View File

@@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
Default: False
Default: True
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
!!! info "Changed in NetBox v4.0.2"
Prior to NetBox v4.0.2, this setting was disabled by default.
---

View File

@@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
---
## TRANSLATION_ENABLED
Default: True
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

View File

@@ -65,12 +65,6 @@ class AnotherCustomScript(Script):
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 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
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.)
![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
```
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.
### 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.
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
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
./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

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.utils.translation import gettext_lazy as _
from dcim.models import Site
from netbox.forms import NetBoxModelImportForm
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import CommentField, DynamicModelChoiceField
from utilities.forms.rendering import FieldSet
from .models import MyModel, MyModelStatusChoices
class MyModelEditForm(NetBoxModelImportForm):
class MyModelBulkEditForm(NetBoxModelBulkEditForm):
name = forms.CharField(
required=False
)

View File

@@ -1,5 +1,88 @@
# NetBox v4.0
## 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
* [#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
* [#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
* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
* [#14948](https://github.com/netbox-community/netbox/issues/14948) - Introduce the `has_virtual_device_context` filter for devices
* [#15353](https://github.com/netbox-community/netbox/issues/15353) - Improve error reporting when custom scripts fail to load
* [#15496](https://github.com/netbox-community/netbox/issues/15496) - Implement dedicated views for management of circuit terminations
* [#15603](https://github.com/netbox-community/netbox/issues/15603) - Add 4G & 5G cellular interface types
* [#15962](https://github.com/netbox-community/netbox/issues/15962) - Enable UNIX socket connections for Redis
### Bug Fixes
* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM
* [#14953](https://github.com/netbox-community/netbox/issues/14953) - Ensure annotated count fields are present in REST API response data when creating new objects
* [#14982](https://github.com/netbox-community/netbox/issues/14982) - Fix OpenAPI schema definition for SerializedPKRelatedFields
* [#15082](https://github.com/netbox-community/netbox/issues/15082) - Strip whitespace from choice values & labels when creating a custom field choice set
* [#16138](https://github.com/netbox-community/netbox/issues/16138) - Fix support for referencing users & groups in object permissions
* [#16145](https://github.com/netbox-community/netbox/issues/16145) - Restore ability to reference custom scripts via module & name in REST API
* [#16164](https://github.com/netbox-community/netbox/issues/16164) - Correct display of selected values in UI when filtering object list by a null value
* [#16173](https://github.com/netbox-community/netbox/issues/16173) - Fix TypeError exception when viewing object list with no pagination preference defined
* [#16228](https://github.com/netbox-community/netbox/issues/16228) - Fix permissions enforcement for GraphQL queries of users & groups
* [#16232](https://github.com/netbox-community/netbox/issues/16232) - Preserve bulk action checkboxes on dynamic tables when using pagination
* [#16240](https://github.com/netbox-community/netbox/issues/16240) - Fixed NoReverseMatch exception when adding circuit terminations to an object counts dashboard widget
---
## v4.0.2 (2024-05-14)
!!! warning "Important"
This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
### Enhancements
* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
### Bug Fixes
* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
---
## v4.0.1 (2024-05-09)
### Enhancements

View File

@@ -42,7 +42,7 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
readthedocs: !ENV READTHEDOCS
build_public: !ENV BUILD_PUBLIC
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox

View File

@@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider_id',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
class Meta:
model = CircuitTermination

View File

@@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DatePicker, NumberWithOptions
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = (
'CircuitBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
'ProviderAccountBulkEditForm',
@@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',
)
class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False
)
provider_network = DynamicModelChoiceField(
label=_('Provider Network'),
queryset=ProviderNetwork.objects.all(),
required=False
)
port_speed = forms.IntegerField(
required=False,
label=_('Port speed (Kbps)'),
)
upstream_speed = forms.IntegerField(
required=False,
label=_('Upstream speed (Kbps)'),
)
mark_connected = forms.NullBooleanField(
label=_('Mark connected'),
required=False,
widget=BulkEditNullBooleanSelect
)
model = CircuitTermination
fieldsets = (
FieldSet(
'description',
TabbedGroups(
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'mark_connected', name=_('Circuit Termination')
),
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
)
nullable_fields = ('description')

View File

@@ -1,10 +1,10 @@
from django import forms
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from circuits.models import *
from dcim.models import Site
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = (
'CircuitImportForm',
'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderAccountImportForm',
@@ -111,7 +112,16 @@ class CircuitImportForm(NetBoxModelImportForm):
]
class CircuitTerminationImportForm(forms.ModelForm):
class BaseCircuitTerminationImportForm(forms.ModelForm):
circuit = CSVModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
to_field_name='cid',
)
term_side = CSVChoiceField(
label=_('Termination'),
choices=CircuitTerminationSideChoices,
)
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
@@ -125,9 +135,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
required=False
)
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description',
'pp_info', 'description'
]
class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'tags'
]

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
@@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
'ProviderAccountFilterForm',
@@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
)
)
tag = TagFilterField(model)
class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
model = CircuitTermination
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('circuit_id', 'term_side', name=_('Circuit')),
FieldSet('provider_id', 'provider_network_id', name=_('Provider')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site')
)
circuit_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
)
term_side = forms.MultipleChoiceField(
label=_('Term Side'),
choices=CircuitTerminationSideChoices,
required=False
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
tag = TagFilterField(model)

View File

@@ -227,7 +227,7 @@ class CircuitTermination(
return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self):
return self.circuit.get_absolute_url()
return reverse('circuits:circuittermination', args=[self.pk])
def clean(self):
super().clean()

View File

@@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex):
display_attrs = ('description',)
@register_search
class ProviderAccountIndex(SearchIndex):
model = models.ProviderAccount
fields = (

View File

@@ -10,6 +10,7 @@ from .columns import CommitRateColumn
__all__ = (
'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable',
)
@@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
)
class CircuitTerminationTable(NetBoxTable):
circuit = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True,
accessor='circuit.provider'
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
provider_network = tables.Column(
verbose_name=_('Provider Network'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = CircuitTermination
fields = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')

View File

@@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
@@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.all()[:2]
circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -5,8 +5,11 @@ from django.urls import reverse
from circuits.choices import *
from circuits.models import *
from core.models import ObjectType
from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR
from netbox.choices import ImportFormatChoices
from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
Site.objects.create(name='Site 1', slug='site-1')
providers = (
Provider(name='Provider 1', slug='provider-1'),
@@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self):
json_data = """
[
{
"cid": "Circuit 7",
"provider": "Provider 1",
"type": "Circuit Type 1",
"status": "active",
"description": "Testing Import",
"terminations": [
{
"term_side": "A",
"site": "Site 1"
},
{
"term_side": "Z",
"site": "Site 1"
}
]
}
]
"""
initial_count = self._get_queryset().count()
data = {
'data': json_data,
'format': ImportFormatChoices.JSON,
}
# Assign model-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
# Test POST with permission
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + 1)
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount
@@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class CircuitTerminationTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination
@classmethod
@@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
'description': 'New description',
}
cls.csv_data = (
"circuit,term_side,site,description",
"Circuit 3,A,Site 1,Foo",
"Circuit 3,Z,Site 1,Bar",
)
cls.csv_update_data = (
"id,port_speed,description",
f"{circuit_terminations[0].pk},100,New description7",
f"{circuit_terminations[1].pk},200,New description8",
f"{circuit_terminations[2].pk},300,New description9",
)
cls.bulk_edit_data = {
'port_speed': 400,
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
device = create_test_device('Device 1')

View File

@@ -48,7 +48,11 @@ urlpatterns = [
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations
path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'),
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'),
path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'),
path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
]

View File

@@ -298,7 +298,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination',
]
related_object_forms = {
'terminations': forms.CircuitTerminationImportForm,
'terminations': forms.CircuitTerminationImportRelatedForm,
}
def prep_related_object_data(self, parent, data):
@@ -408,6 +408,18 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations
#
class CircuitTerminationListView(generic.ObjectListView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
@@ -419,5 +431,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
class CircuitTerminationBulkEditView(generic.BulkEditView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
form = forms.CircuitTerminationBulkEditForm
class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)

View File

@@ -255,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects."
class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.SerializedPKRelatedField'
def map_serializer_field(self, auto_schema, direction):
if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction)
return component.ref if component else None
else:
return build_basic_type(OpenApiTypes.INT)

View File

@@ -224,7 +224,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
for param in PARAMS:
params.append((
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)
))

View File

@@ -21,7 +21,7 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Region
@@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = SiteGroup
@@ -86,8 +86,8 @@ class LocationSerializer(NestedGroupModelSerializer):
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
rack_count = serializers.IntegerField(read_only=True, default=0)
device_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Location

View File

@@ -399,6 +399,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -520,6 +524,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -635,6 +644,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -749,6 +762,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -810,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_FIXED = '100base-tx'
TYPE_100ME_T1 = '100base-t1'
TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
TYPE_2GE_FIXED = '2.5gbase-t'
@@ -874,6 +893,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_GSM = 'gsm'
TYPE_CDMA = 'cdma'
TYPE_LTE = 'lte'
TYPE_4G = '4g'
TYPE_5G = '5g'
# SONET
TYPE_SONET_OC3 = 'sonet-oc3'
@@ -921,12 +942,15 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_DOCSIS = 'docsis'
# PON
TYPE_BPON = 'bpon'
TYPE_EPON = 'epon'
TYPE_10G_EPON = '10g-epon'
TYPE_GPON = 'gpon'
TYPE_XG_PON = 'xg-pon'
TYPE_XGS_PON = 'xgs-pon'
TYPE_NG_PON2 = 'ng-pon2'
TYPE_EPON = 'epon'
TYPE_10G_EPON = '10g-epon'
TYPE_25G_PON = '25g-pon'
TYPE_50G_PON = '50g-pon'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
@@ -964,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
@@ -1042,6 +1067,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
(TYPE_LTE, 'LTE'),
(TYPE_4G, '4G'),
(TYPE_5G, '5G'),
)
),
(
@@ -1110,12 +1137,15 @@ class InterfaceTypeChoices(ChoiceSet):
(
'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_XGS_PON, 'XGS-PON (10 Gbps)'),
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
(TYPE_EPON, 'EPON (1 Gbps)'),
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
(TYPE_25G_PON, '25G-PON (25 Gbps)'),
(TYPE_50G_PON, '50G-PON (50 Gbps)'),
)
),
(

View File

@@ -1100,6 +1100,10 @@ class DeviceFilterSet(
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
has_virtual_device_context = django_filters.BooleanFilter(
method='_has_virtual_device_context',
label=_('Has virtual device context'),
)
class Meta:
model = Device
@@ -1176,6 +1180,12 @@ class DeviceFilterSet(
def _device_bays(self, queryset, name, 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):
device_id = django_filters.ModelMultipleChoiceFilter(

View File

@@ -657,6 +657,7 @@ class DeviceFilterForm(
),
FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
'has_virtual_device_context',
name=_('Miscellaneous')
)
)
@@ -813,6 +814,13 @@ class DeviceFilterForm(
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)

View File

@@ -355,11 +355,11 @@ class CableTermination(ChangeLoggedModel):
super().save(*args, **kwargs)
# Set the cable on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=self.cable,
cable_end=self.cable_end
)
termination = self.termination._meta.model.objects.get(pk=self.termination_id)
termination.snapshot()
termination.cable = self.cable
termination.cable_end = self.cable_end
termination.save()
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
#
@@ -313,6 +305,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Module'),
linkify=True
)
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
class CableTerminationTable(NetBoxTable):
@@ -335,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
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):
return ', '.join([
f"{termination.parent_object} > {termination}" for termination in value
@@ -366,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsolePort
fields = (
'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')
@@ -382,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
extra_buttons=CONSOLEPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -410,7 +411,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsoleServerPort
fields = (
'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')
@@ -427,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
extra_buttons=CONSOLESERVERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -461,8 +459,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -479,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
extra_buttons=POWERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
@@ -488,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -513,8 +508,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -530,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
extra_buttons=POWEROUTLET_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
@@ -539,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class BaseInterfaceTable(NetBoxTable):
@@ -618,10 +610,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'),
linkify=True
)
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
@@ -713,8 +701,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.FrontPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'created', 'last_updated',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
extra_buttons=FRONTPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.FrontPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
@@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
@@ -766,7 +751,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.RearPort
fields = (
'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')
@@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
extra_buttons=REARPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.RearPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
@@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
)
row_attrs = {
'class': get_cabletermination_row_class
}
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[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):
params = {'q': 'foobar1'}
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]}
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):
queryset = Module.objects.all()

View File

@@ -2093,7 +2093,6 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2986,7 +2985,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),

View File

@@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
changed_object = serializers.SerializerMethodField(
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:
model = ObjectChange

View File

@@ -43,7 +43,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
def validate(self, data):
# 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:
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
except ObjectDoesNotExist:
@@ -51,10 +51,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
)
# Enforce model validation
super().validate(data)
return data
return super().validate(data)
@extend_schema_field(serializers.JSONField(allow_null=True))
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_rq.queues import get_connection
from rest_framework import status
@@ -215,21 +216,32 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True
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):
script = get_object_or_404(self.queryset, pk=pk)
script = self._get_script(pk)
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
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'):
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(
data=request.data,
context={'script': script}
@@ -240,9 +252,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
script.result = Job.enqueue(
Job.enqueue(
run_script,
instance=script.module,
instance=script,
name=script.python_class.class_name,
user=request.user,
data=input_serializer.data['data'],

View File

@@ -13,13 +13,14 @@ def event_tracking(request):
:param request: WSGIRequest object with a unique `id` set
"""
current_request.set(request)
events_queue.set([])
events_queue.set({})
yield
# Flush queued webhooks to RQ
flush_events(events_queue.get())
if events := list(events_queue.get().values()):
flush_events(events)
# Clear context vars
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 {}
if page_size := self.config.get('page_size'):
parameters['per_page'] = page_size
parameters['embedded'] = True
if parameters:
try:

View File

@@ -58,15 +58,21 @@ def enqueue_object(queue, instance, user, request_id, action):
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
return
queue.append({
'content_type': ContentType.objects.get_for_model(instance),
'object_id': instance.pk,
'event': action,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, action),
'username': user.username,
'request_id': request_id
})
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),
'object_id': instance.pk,
'event': action,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, action),
'username': user.username,
'request_id': request_id
}
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
@@ -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:
try:
func = import_string(name)
func(queue)
func(events)
except Exception as 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,
label=_('User')
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ObjectType.objects.all(),
assigned_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('journaling'),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
)
kind = forms.ChoiceField(
label=_('Kind'),
@@ -507,11 +504,8 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('User')
)
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ObjectType.objects.all(),
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
)

View File

@@ -122,7 +122,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))
data.append((value.strip(), label.strip()))
return data

View File

@@ -1,12 +1,17 @@
from functools import cached_property
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.models import ObjectType
from extras.choices import *
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from ..querysets import ObjectChangeQuerySet
__all__ = (
@@ -136,6 +141,71 @@ class ObjectChange(models.Model):
def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)
@property
@cached_property
def has_changes(self):
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

@@ -96,6 +96,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
Proxy model for script module files.
"""
objects = ScriptModuleManager()
error = None
event_rules = GenericRelation(
to='extras.EventRule',
@@ -126,6 +127,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
try:
module = self.get_module()
except Exception as e:
self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None

View File

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models, transaction
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel
@@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete()
# Rebuild the MPTT tree where applicable
if issubclass(self.model, MPTTModel):
self.model.objects.rebuild()
apply.alters_data = True
def get_action_color(self):

View File

@@ -55,18 +55,6 @@ def run_validators(instance, validators):
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))
def handle_changed_object(sender, instance, **kwargs):
"""
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.request_id = request.id
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()
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)
# Increment metric counters
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
events_queue.set(queue)
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
"""
logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
events_queue.set([])
events_queue.set({})
#

View File

@@ -1,10 +1,10 @@
import json
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import *
@@ -550,7 +550,7 @@ class ScriptResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'time', 'status', 'message',
)
@@ -581,7 +581,7 @@ class ReportResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message',
)

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['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):
site = Site(name='Site 1', slug='site-1')
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['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):
site = Site(
name='Site 1',
@@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
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):
sites = (
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['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):
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
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):
site = Site(
name='Site 1',
@@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
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):
data = (
{

View File

@@ -4,6 +4,7 @@ from unittest.mock import patch
import django_rq
from django.http import HttpResponse
from django.test import RequestFactory
from django.urls import reverse
from requests import Session
from rest_framework import status
@@ -12,6 +13,7 @@ from core.models import ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
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.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook
@@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
return HttpResponse()
# Enqueue a webhook for processing
webhooks_queue = []
webhooks_queue = {}
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_object(
webhooks_queue,
@@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
)
flush_events(webhooks_queue)
flush_events(list(webhooks_queue.values()))
# Retrieve the job from queue
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
with patch.object(Session, 'send', dummy_send) as mock_send:
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:
non_atomic_change = True
prechange_data = prev_change.postchange_data
prechange_data = prev_change.postchange_data_clean
else:
non_atomic_change = False
prechange_data = instance.prechange_data
prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prechange_data or dict(),
instance.postchange_data or dict(),
instance.postchange_data_clean or dict(),
exclude=['last_updated'],
)
diff_removed = {
@@ -1052,12 +1052,27 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
})
class ScriptView(generic.ObjectView):
class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all()
def _get_script_class(self, script):
"""
Return an instance of the Script's Python class
"""
if script_class := script.python_class:
return script_class()
class ScriptView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = script.python_class()
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'script': script,
})
form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', {
@@ -1069,11 +1084,16 @@ class ScriptView(generic.ObjectView):
def post(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = script.python_class()
if not request.user.has_perm('extras.run_script', obj=script):
return HttpResponseForbidden()
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'script': script,
})
form = script_class.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
@@ -1103,21 +1123,22 @@ class ScriptView(generic.ObjectView):
})
class ScriptSourceView(generic.ObjectView):
class ScriptSourceView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = self._get_script_class(script)
return render(request, 'extras/script/source.html', {
'script': script,
'script_class': script.python_class(),
'script_class': script_class,
'job_count': script.jobs.count(),
'tab': 'source',
})
class ScriptJobsView(generic.ObjectView):
class ScriptJobsView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):

View File

@@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN
__all__ = (
@@ -168,6 +168,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
),
FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -249,6 +250,12 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('VLAN'),
)
tag = TagFilterField(model)
@@ -405,6 +412,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
)
model = VLANGroup
@@ -445,6 +453,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model)

View File

@@ -355,6 +355,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
})
elif type(instance.assigned_object) is VMInterface:
self.fields['vminterface'].widget.add_query_params({
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
})
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True

View File

@@ -18,6 +18,7 @@ from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = (
'Aggregate',
@@ -74,7 +75,7 @@ class RIR(OrganizationalModel):
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
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])
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
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)
class IPRange(PrimaryModel):
class IPRange(ContactsMixin, PrimaryModel):
"""
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)
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
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.constants import *
from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin
from utilities.data import array_to_string
__all__ = (
@@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
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
optionally be tied to one or more specific IPAddresses belonging to its parent.

View File

@@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
graphql_filter = {
'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
}
@classmethod
def setUpTestData(cls):

View File

@@ -9,6 +9,7 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import ViewTab, register_model_view
@@ -214,7 +215,6 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -406,6 +406,11 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable
@register_model_view(Aggregate, 'contacts')
class AggregateContactsView(ObjectContactsView):
queryset = Aggregate.objects.all()
#
# Prefix/VLAN roles
#
@@ -644,6 +649,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
@register_model_view(Prefix, 'contacts')
class PrefixContactsView(ObjectContactsView):
queryset = Prefix.objects.all()
#
# IP Ranges
#
@@ -727,6 +737,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
@register_model_view(IPRange, 'contacts')
class IPRangeContactsView(ObjectContactsView):
queryset = IPRange.objects.all()
#
# IP addresses
#
@@ -883,7 +898,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -895,6 +909,11 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
return parent.get_related_ips().restrict(request.user, 'view')
@register_model_view(IPAddress, 'contacts')
class IPAddressContactsView(ObjectContactsView):
queryset = IPAddress.objects.all()
#
# VLAN groups
#
@@ -955,7 +974,6 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@@ -1111,7 +1129,6 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1129,7 +1146,6 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
@@ -1264,3 +1280,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
@register_model_view(Service, 'contacts')
class ServiceContactsView(ObjectContactsView):
queryset = Service.objects.all()

View File

@@ -157,9 +157,8 @@ LOGGING = {}
# authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox but not make any changes.
LOGIN_REQUIRED = False
# Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
LOGIN_REQUIRED = True
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days])

View File

@@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'

View File

@@ -7,4 +7,4 @@ __all__ = (
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):
pass
elif isinstance(field, MultiValueCharFilter):
should_create_function = True
attr_type = List[str] | None
# Note: Need to use the legacy FilterLookup from filters, not from
# strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
attr_type = strawberry_django.filters.FilterLookup[str] | None
elif isinstance(field, MultiValueDateFilter):
attr_type = auto
elif isinstance(field, MultiValueDateTimeFilter):
@@ -87,7 +88,7 @@ def map_strawberry_type(field):
pass
elif issubclass(type(field), django_filters.NumberFilter):
should_create_function = True
attr_type = int
attr_type = int | None
elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
should_create_function = True
attr_type = List[str] | None

View File

@@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
items=(
get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
),
),
MenuGroup(
@@ -372,19 +373,19 @@ ADMIN_MENU = Menu(
link=f'users:user_list',
link_text=_('Users'),
auth_required=True,
permissions=[f'auth.view_user'],
permissions=[f'users.view_user'],
buttons=(
MenuItemButton(
link=f'users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_user']
permissions=[f'users.add_user']
),
MenuItemButton(
link=f'users:user_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_user']
permissions=[f'users.add_user']
)
)
),
@@ -392,19 +393,19 @@ ADMIN_MENU = Menu(
link=f'users:group_list',
link_text=_('Groups'),
auth_required=True,
permissions=[f'auth.view_group'],
permissions=[f'users.view_group'],
buttons=(
MenuItemButton(
link=f'users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group']
permissions=[f'users.add_group']
),
MenuItemButton(
link=f'users:group_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_group']
permissions=[f'users.add_group']
)
)
),

View File

@@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
)
# Verify required configuration settings

View File

@@ -23,7 +23,7 @@ PREFERENCES = {
),
description=_('Enable dynamic UI navigation'),
default=False,
experimental=True
warning=_('Experimental feature')
),
'locale.language': UserPreference(
label=_('Language'),
@@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')),
*settings.LANGUAGES,
),
description=_('Forces UI translation to the specified language.')
description=_('Forces UI translation to the specified language'),
warning=(
_("Support for translation has been disabled locally")
if not settings.TRANSLATION_ENABLED
else ''
)
),
'pagination.per_page': UserPreference(
label=_('Page length'),

View File

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
VERSION = '4.0.1'
VERSION = '4.0.5'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@@ -156,6 +156,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS:
@@ -241,6 +242,7 @@ if 'tasks' not in REDIS:
TASKS_REDIS = REDIS['tasks']
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
TASKS_REDIS_URL = TASKS_REDIS.get('URL')
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
TASKS_REDIS_USING_SENTINEL = all([
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)),
@@ -269,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_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_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
CACHES = {
@@ -366,6 +368,8 @@ INSTALLED_APPS = [
'drf_spectacular',
'drf_spectacular_sidecar',
]
if not DEBUG:
INSTALLED_APPS.remove('debug_toolbar')
if not DJANGO_ADMIN_ENABLED:
INSTALLED_APPS.remove('django.contrib.admin')
@@ -445,6 +449,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects
USE_TZ = True
# Toggle language translation support
USE_I18N = TRANSLATION_ENABLED
# WSGI
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -674,6 +681,12 @@ if TASKS_REDIS_USING_SENTINEL:
'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:
RQ_PARAMS = {
'HOST': TASKS_REDIS_HOST,
@@ -708,6 +721,7 @@ RQ_QUEUES.update({
# Supported translation languages
LANGUAGES = (
('de', _('German')),
('en', _('English')),
('es', _('Spanish')),
('fr', _('French')),
@@ -715,6 +729,8 @@ LANGUAGES = (
('pt', _('Portuguese')),
('ru', _('Russian')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
('zh', _('Chinese')),
)
LOCALE_PATHS = (
BASE_DIR + '/translations',
@@ -801,3 +817,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
})
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *
_UNSUPPORTED_SETTINGS = True
except ImportError:
pass

View File

@@ -1,4 +1,5 @@
from copy import deepcopy
from functools import cached_property
import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser
@@ -14,6 +15,7 @@ from django_tables2.data import TableQuerysetData
from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -188,6 +190,7 @@ class NetBoxTable(BaseTable):
actions = columns.ActionsColumn()
exempt_columns = ('pk', 'actions')
embedded = False
class Meta(BaseTable.Meta):
pass
@@ -217,12 +220,12 @@ class NetBoxTable(BaseTable):
super().__init__(*args, extra_columns=extra_columns, **kwargs)
@property
@cached_property
def htmx_url(self):
"""
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')
try:
return reverse(viewname)
@@ -258,7 +261,7 @@ class SearchTable(tables.Table):
attrs = {
'class': 'table table-hover object-list',
}
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
def __init__(self, data, highlight=None, **kwargs):
self.highlight = highlight

View File

@@ -42,6 +42,7 @@ class PluginTest(TestCase):
url = reverse('admin:dummy_plugin_dummymodel_add')
self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
@override_settings(LOGIN_REQUIRED=False)
def test_views(self):
# Test URL resolution
@@ -53,7 +54,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_api_views(self):
# Test URL resolution
@@ -65,6 +66,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
@override_settings(LOGIN_REQUIRED=False)
def test_registered_views(self):
# Test URL resolution

View File

@@ -1,24 +1,76 @@
import urllib.parse
from utilities.testing import TestCase
from django.urls import reverse
from django.test import override_settings
from dcim.models import Site
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.search.backends import search_backend
from utilities.testing import TestCase
class HomeViewTestCase(TestCase):
def test_home(self):
url = reverse('home')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search(self):
class SearchViewTestCase(TestCase):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site Alpha', slug='alpha', description='Red'),
Site(name='Site Bravo', slug='bravo', description='Red'),
Site(name='Site Charlie', slug='charlie', description='Green'),
Site(name='Site Delta', slug='delta', description='Green'),
Site(name='Site Echo', slug='echo', description='Blue'),
Site(name='Site Foxtrot', slug='foxtrot', description='Blue'),
)
Site.objects.bulk_create(sites)
search_backend.cache(sites)
def test_search(self):
url = reverse('search')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search_query(self):
url = reverse('search')
params = {
'q': 'foo',
'q': 'red',
}
query = urllib.parse.urlencode(params)
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
# Test without view permission
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)
# Add view permissions & query again. Only matching objects should be listed
self.add_permissions('dcim.view_site')
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn('Site Alpha', content)
self.assertIn('Site Bravo', content)
self.assertNotIn('Site Charlie', content)
self.assertNotIn('Site Delta', content)
self.assertNotIn('Site Echo', content)
self.assertNotIn('Site Foxtrot', content)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_search_no_results(self):
url = reverse('search')
params = {
'q': 'xxxxxxxxx', # Matches nothing
}
query = urllib.parse.urlencode(params)
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)

View File

@@ -163,7 +163,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
if not request.htmx.target:
if request.GET.get('embedded', False):
table.embedded = True
# Hide selection checkboxes
if 'pk' in table.base_columns:

View File

@@ -93,6 +93,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model = None
table = None
filterset = None
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
"""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -27,10 +27,10 @@
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "10.1.2",
"gridstack": "10.2.0",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
"sass": "1.77.0",
"sass": "1.77.4",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -7,38 +7,74 @@ import { isTruthy } from './util';
*/
function quickSearchEventHandler(event: Event): void {
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 (quicksearch.value === "") {
clearbtn.classList.add("invisible");
if (quicksearch.value === '') {
clearbtn.classList.add('invisible');
} 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.
*/
export function initQuickSearch(): void {
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
const quicksearch = document.getElementById('quicksearch') as HTMLInputElement;
const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
if (isTruthy(quicksearch)) {
quicksearch.addEventListener("keyup", quickSearchEventHandler, {
passive: true
})
quicksearch.addEventListener("search", quickSearchEventHandler, {
passive: true
})
quicksearch.addEventListener('keyup', quickSearchEventHandler, {
passive: true,
});
quicksearch.addEventListener('search', quickSearchEventHandler, {
passive: true,
});
quicksearch.addEventListener('change', handleQuickSearchParams, {
passive: true,
});
if (isTruthy(clearbtn)) {
clearbtn.addEventListener("click", async () => {
const search = new Event('search');
quicksearch.value = '';
await new Promise(f => setTimeout(f, 100));
quicksearch.dispatchEvent(search);
}, {
passive: true
})
clearbtn.addEventListener(
'click',
async () => {
const search = new Event('search');
quicksearch.value = '';
await new Promise(f => setTimeout(f, 100));
quicksearch.dispatchEvent(search);
clearLinkParams();
},
{
passive: true,
},
);
}
}
}

View File

@@ -1,7 +1,7 @@
// Global variables
// Set base fonts
$font-family-base: 'Inter';
$font-family-sans-serif: 'Inter';
// See https://github.com/tabler/tabler/issues/1812
$font-family-monospace: 'Roboto Mono';

View File

@@ -1,7 +1,7 @@
// Serialized data from change records
pre.change-data {
padding-right: 0;
padding-left: 0;
border-radius: 0;
padding: 0;
// Display each line individually for highlighting
> 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
pre {
background-color: transparent;
@@ -32,3 +39,8 @@ table a {
// Adjust table anchor link contrast as not enough contrast in dark mode
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"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
gridstack@10.1.2:
version "10.1.2"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.1.2.tgz#58b5ae0057a8aa5e4f6563041c4ca2def3aa4268"
integrity sha512-Nn27XGQ68WtBC513cKQQ4t/dA2uuN/xnNUU50puXEJv6IFk5SzT0Dnsq68GpopO1n0tXUKZKm1Rw7uOUMDz1KQ==
gridstack@10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.0.tgz#4ba9c7ee69a730851721a9f5cb33dc55026ded1f"
integrity sha512-svKAOq/dfinpvhe/nnxdyZOOEd9qynXiOPHvL96PALE0yWChWp/6lechnqKwud0tL/rRyAfMJ6Hh/z2fS13pBA==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
@@ -1816,9 +1816,9 @@ ignore@^5.2.0:
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immutable@^4.0.0:
version "4.3.5"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0"
integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==
version "4.3.6"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
import-fresh@^3.2.1:
version "3.3.0"
@@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.77.0:
version "1.77.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.0.tgz#e736c69aff9fae4a4e6dae60a979eee9c942f321"
integrity sha512-eGj4HNfXqBWtSnvItNkn7B6icqH14i3CiCGbzMKs3BAPTq62pp9NBYsBgyN4cA+qssqo9r26lW4JSvlaUUWbgw==
sass@1.77.4:
version "1.77.4"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.4.tgz#92059c7bfc56b827c56eb116778d157ec017a5cd"
integrity sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"

View File

@@ -0,0 +1,51 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
{% if object %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>
{{ object.circuit|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>
{{ object.circuit.provider|linkify }}
</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
</table>
{% else %}
<div class="card-body">
<span class="text-muted">{% trans "None" %}</span>
</div>
{% endif %}
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -27,93 +27,7 @@
</h5>
{% if termination %}
<table class="table table-hover attr-table">
{% if termination.site %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if termination.site.region %}
{{ termination.site.region|linkify }} /
{% endif %}
{{ termination.site|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Termination" %}</th>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
<tr>
<th scope="row">{% trans "Tags" %}</th>
<td>

View File

@@ -0,0 +1,90 @@
{% load helpers %}
{% load i18n %}
{% if termination.site %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if termination.site.region %}
{{ termination.site.region|linkify }} /
{% endif %}
{{ termination.site|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Termination" %}</th>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th scope="row">{% trans "Provider Network" %}</th>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Speed" %}</th>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cross-Connect" %}</th>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Patch Panel/Port" %}</th>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ termination.description|placeholder }}</td>
</tr>

View File

@@ -33,7 +33,7 @@
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Configuration Data" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %}
{% include 'core/inc/config_data.html' with config=object.data %}
</div>
<div class="card">

View File

@@ -5,7 +5,7 @@
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<div class="card-body table-responsive">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>

View File

@@ -40,7 +40,7 @@
<td>{{ stats.django_version }}</td>
</tr>
<tr>
<th scope="row">{% trans "PotsgreSQL version" %}</th>
<th scope="row">{% trans "PostgreSQL version" %}</th>
<td>{{ stats.postgresql_version }}</td>
</tr>
<tr>

View File

@@ -5,6 +5,7 @@
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% load l10n %}
{% load mptt %}
{% block content %}
@@ -63,7 +64,7 @@
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<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" %}
</a>
</div>

View File

@@ -3,6 +3,7 @@
{% load plugins %}
{% load tz %}
{% load i18n %}
{% load l10n %}
{% load mptt %}
{% block breadcrumbs %}
@@ -95,7 +96,7 @@
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<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" %}
</a>
</div>

View File

@@ -1,5 +1,6 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block title %}{{ object }}{% endblock %}
@@ -22,7 +23,7 @@
{% block subtitle %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="row">
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">{% trans "Change" %}</h5>
@@ -104,7 +105,7 @@
</div>
</div>
</div>
<div class="row mb-3">
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
@@ -112,7 +113,7 @@
{% if object.prechange_data %}
{% spaceless %}
<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>
{% endfor %}
</pre>
@@ -132,7 +133,7 @@
{% if object.postchange_data %}
{% spaceless %}
<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>
{% endfor %}
</pre>
@@ -144,7 +145,15 @@
</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">
{% 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 %}
@@ -158,4 +167,9 @@
{% endif %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -14,38 +14,43 @@
{% trans "You do not have permission to run scripts" %}.
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
{% csrf_token %}
<div class="field-group my-4">
{# Render grouped fields according to declared fieldsets #}
{% for group, fields in script_class.get_fieldsets %}
{% if fields %}
<div class="field-group mb-5">
<div class="row">
<h5 class="col-9 offset-3">{{ group }}</h5>
{% if form %}
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
{% csrf_token %}
<div class="field-group my-4">
{# Render grouped fields according to declared fieldsets #}
{% for group, fields in script_class.get_fieldsets %}
{% if fields %}
<div class="field-group mb-5">
<div class="row">
<h5 class="col-9 offset-3">{{ group }}</h5>
</div>
{% for name in fields %}
{% with field=form|getfield:name %}
{% render_field field %}
{% endwith %}
{% endfor %}
</div>
{% for name in fields %}
{% with field=form|getfield:name %}
{% render_field field %}
{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
<div class="text-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
{% if not request.user|can_run:script or not script.is_executable %}
<button class="btn btn-primary" disabled>
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
</button>
{% else %}
<button type="submit" name="_run" class="btn btn-primary">
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
</button>
{% endif %}
{% endfor %}
</div>
<div class="text-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
{% if not request.user|can_run:script or not script.is_executable %}
<button class="btn btn-primary" disabled>
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
</button>
{% else %}
<button type="submit" name="_run" class="btn btn-primary">
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
</button>
{% endif %}
</div>
</form>
</div>
</form>
{% else %}
<p>{% trans "Error loading script" %}.</p>
<pre class="block">{{ script.module.error }}</pre>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -4,7 +4,7 @@
{% load log_levels %}
{% load i18n %}
{% block title %}{{ script }}{% endblock %}
{% block title %}{{ script.python_class.name }}{% endblock %}
{% block object_identifier %}
{{ script.full_name }}
@@ -17,7 +17,7 @@
{% block subtitle %}
<div class="text-secondary fs-5">
{{ script.Meta.description|markdown }}
{{ script.python_class.Meta.description|markdown }}
</div>
{% endblock subtitle %}

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