Compare commits

..

40 Commits

Author SHA1 Message Date
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
79 changed files with 49462 additions and 4757 deletions

View File

@@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.2
placeholder: v4.0.3
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.2
placeholder: v4.0.3
validations:
required: true
- type: dropdown

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

@@ -17,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
days-before-issue-stale: 90
days-before-issue-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100
remove-stale-when-updated: false
stale-issue-label: 'pending closure'
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
@@ -38,6 +39,12 @@ jobs:
process by "bumping" the issue; doing so will result in its immediate closure
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

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ yarn-error.log*
/venv/
/*.sh
local_requirements.txt
local_settings.py
!upgrade.sh
fabfile.py
gunicorn.py

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

@@ -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",
@@ -375,6 +381,8 @@
"gsm",
"cdma",
"lte",
"4g",
"5g",
"sonet-oc3",
"sonet-oc12",
"sonet-oc48",
@@ -408,12 +416,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

@@ -1,5 +1,37 @@
# NetBox v4.0
## 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"

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

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

@@ -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'),
)),
@@ -874,6 +892,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 +941,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'
@@ -1042,6 +1065,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
(TYPE_LTE, 'LTE'),
(TYPE_4G, '4G'),
(TYPE_5G, '5G'),
)
),
(
@@ -1110,12 +1135,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

@@ -313,6 +313,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Module'),
linkify=True
)
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
class CableTerminationTable(NetBoxTable):
@@ -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')
@@ -410,7 +414,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')
@@ -461,8 +465,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')
@@ -513,8 +517,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')
@@ -618,10 +622,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 +713,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',
@@ -766,7 +766,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')

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

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

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

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

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

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

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

@@ -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
@@ -405,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
#
@@ -643,6 +649,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
@register_model_view(Prefix, 'contacts')
class PrefixContactsView(ObjectContactsView):
queryset = Prefix.objects.all()
#
# IP Ranges
#
@@ -726,6 +737,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
@register_model_view(IPRange, 'contacts')
class IPRangeContactsView(ObjectContactsView):
queryset = IPRange.objects.all()
#
# IP addresses
#
@@ -893,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
#

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

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
VERSION = '4.0.2'
VERSION = '4.0.3'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -242,6 +242,7 @@ if 'tasks' not in REDIS:
TASKS_REDIS = REDIS['tasks']
TASKS_REDIS_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)),
@@ -270,7 +271,7 @@ CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'defau
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
CACHING_REDIS_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 = {
@@ -678,6 +679,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,
@@ -712,6 +719,7 @@ RQ_QUEUES.update({
# Supported translation languages
LANGUAGES = (
('de', _('German')),
('en', _('English')),
('es', _('Spanish')),
('fr', _('French')),
@@ -719,6 +727,8 @@ LANGUAGES = (
('pt', _('Portuguese')),
('ru', _('Russian')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
('zh', _('Chinese')),
)
LOCALE_PATHS = (
BASE_DIR + '/translations',

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
@@ -189,6 +190,7 @@ class NetBoxTable(BaseTable):
actions = columns.ActionsColumn()
exempt_columns = ('pk', 'actions')
embedded = False
class Meta(BaseTable.Meta):
pass
@@ -218,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)

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:

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,7 @@
"gridstack": "10.1.2",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
"sass": "1.77.1",
"sass": "1.77.2",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

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

@@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.77.1:
version "1.77.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.1.tgz#018cdfb206afd14724030c02e9fefd8f30a76cd0"
integrity sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==
sass@1.77.2:
version "1.77.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.2.tgz#18d4ed2eefc260cdc8099c5439ec1303fd5863aa"
integrity sha512-eb4GZt1C3avsX3heBNlrc7I09nyT00IUuo4eFhAbeXWU2fvA7oXI53SxODVAA+zgZCk9aunAZgO+losjR3fAwA==
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

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

@@ -1,6 +1,14 @@
{% extends 'extras/script/base.html' %}
{% load i18n %}
{% block content %}
<code class="h6 my-3 d-block">{{ script_class.filename }}</code>
<pre class="block">{{ script_class.source }}</pre>
{% if script_class %}
<code class="h6 my-3 d-block">{{ script_class.filename }}</code>
<pre class="block">{{ script_class.source }}</pre>
{% else %}
<p>{% trans "Error loading script" %}.</p>
<pre class="block">{{ script.module.error }}</pre>
{% endif %}
{% endblock %}

View File

@@ -21,7 +21,7 @@ __all__ = (
class ContactGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
contact_count = serializers.IntegerField(read_only=True)
contact_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = ContactGroup

View File

@@ -14,7 +14,7 @@ __all__ = (
class TenantGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
parent = NestedTenantGroupSerializer(required=False, allow_null=True)
tenant_count = serializers.IntegerField(read_only=True)
tenant_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = TenantGroup

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,7 @@ from django.db.models import Q
OBJECTPERMISSION_OBJECT_TYPES = Q(
~Q(app_label__in=['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) |
Q(app_label='auth', model__in=['group', 'user']) |
Q(app_label='users', model__in=['objectpermission', 'token'])
Q(app_label='users', model__in=['objectpermission', 'token', 'group', 'user'])
)
CONSTRAINT_TOKEN_USER = '$user'

View File

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

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.0.5 on 2024-05-15 18:05
from django.db import migrations, models
def update_content_types(apps, schema_editor):
ObjectType = apps.get_model('core', 'ObjectType')
ObjectPermission = apps.get_model('users', 'ObjectPermission')
auth_group_ct = ObjectType.objects.filter(app_label='auth', model='group').first()
users_group_ct = ObjectType.objects.filter(app_label='users', model='group').first()
if auth_group_ct and users_group_ct:
perms = ObjectPermission.objects.filter(object_types__in=[auth_group_ct])
for perm in perms:
perm.object_types.remove(auth_group_ct)
perm.object_types.add(users_group_ct)
perm.save()
class Migration(migrations.Migration):
dependencies = [
('users', '0008_flip_objectpermission_assignments'),
]
operations = [
# Update ContentTypes
migrations.RunPython(
code=update_content_types,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name='objectpermission',
name='object_types',
field=models.ManyToManyField(
limit_choices_to=models.Q(
models.Q(
models.Q(
(
'app_label__in',
['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'],
),
_negated=True,
),
models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token', 'group', 'user'])),
_connector='OR',
)
),
related_name='object_permissions',
to='core.objecttype',
),
),
]

View File

@@ -197,6 +197,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
# string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value]
return [None, *super().clean(value)]
return super().clean(value)

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ __all__ = (
class WirelessLANGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail')
parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None)
wirelesslan_count = serializers.IntegerField(read_only=True)
wirelesslan_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = WirelessLANGroup

View File

@@ -20,17 +20,17 @@ feedparser==6.0.11
gunicorn==22.0.0
Jinja2==3.1.4
Markdown==3.6
mkdocs-material==9.5.22
mkdocs-material==9.5.24
mkdocstrings[python-legacy]==0.25.1
netaddr==1.2.1
nh3==0.2.17
Pillow==10.3.0
psycopg[c,pool]==3.1.19
PyYAML==6.0.1
requests==2.31.0
requests==2.32.2
social-auth-app-django==5.4.1
social-auth-core==4.5.4
strawberry-graphql==0.229.0
strawberry-graphql==0.230.0
strawberry-graphql-django==0.40.0
svgwrite==1.4.3
tablib==3.6.1