mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
commit
3f345cdbee
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
32
.github/workflows/close-incomplete-issues.yml
vendored
Normal file
32
.github/workflows/close-incomplete-issues.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
|
||||
name: Close incomplete issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '15 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue is being closed as no further information has been provided. If
|
||||
you would like to revisit this topic, please first modify your original post
|
||||
to include all the requested detail, and then ask that the issue be reopened.
|
||||
days-before-stale: 7
|
||||
days-before-close: 7
|
||||
only-issue-labels: 'status: revisions needed'
|
||||
operations-per-run: 100
|
||||
remove-stale-when-updated: false
|
||||
stale-issue-label: 'pending closure'
|
||||
stale-issue-message: >
|
||||
This is a reminder that additional information is needed in order to further
|
||||
triage this issue. If the requested details are not provided, the issue will
|
||||
soon be closed automatically.
|
19
.github/workflows/close-stale-issues.yml
vendored
19
.github/workflows/close-stale-issues.yml
vendored
@ -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
1
.gitignore
vendored
@ -17,6 +17,7 @@ yarn-error.log*
|
||||
/venv/
|
||||
/*.sh
|
||||
local_requirements.txt
|
||||
local_settings.py
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
gunicorn.py
|
||||
|
12
README.md
12
README.md
@ -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">
|
||||
|
@ -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",
|
||||
|
4
docs/_theme/main.html
vendored
4
docs/_theme/main.html
vendored
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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'
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
@ -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]}
|
||||
|
@ -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')
|
||||
|
@ -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'))),
|
||||
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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']
|
||||
)
|
||||
)
|
||||
),
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
@ -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"
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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"
|
||||
|
51
netbox/templates/circuits/circuittermination.html
Normal file
51
netbox/templates/circuits/circuittermination.html
Normal file
@ -0,0 +1,51 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
|
||||
<div class="card">
|
||||
{% if object %}
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Circuit" %}</th>
|
||||
<td>
|
||||
{{ object.circuit|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Provider" %}</th>
|
||||
<td>
|
||||
{{ object.circuit.provider|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -27,93 +27,7 @@
|
||||
</h5>
|
||||
{% 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 }}
|
||||
<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>
|
||||
|
@ -0,0 +1,90 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if termination.site %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if termination.site.region %}
|
||||
{{ termination.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ termination.site|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Termination" %}</th>
|
||||
<td>
|
||||
{% if termination.mark_connected %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
<span class="text-muted">{% trans "Marked as connected" %}</span>
|
||||
{% elif termination.cable %}
|
||||
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
|
||||
{% for peer in termination.link_peers %}
|
||||
{% if peer.device %}
|
||||
{{ peer.device|linkify }}<br/>
|
||||
{% elif peer.circuit %}
|
||||
{{ peer.circuit|linkify }}<br/>
|
||||
{% endif %}
|
||||
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
<div class="mt-1">
|
||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
|
||||
</a>
|
||||
{% if perms.dcim.change_cable %}
|
||||
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_cable %}
|
||||
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
|
||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Provider Network" %}</th>
|
||||
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Speed" %}</th>
|
||||
<td>
|
||||
{% if termination.port_speed and termination.upstream_speed %}
|
||||
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }}
|
||||
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
|
||||
{% elif termination.port_speed %}
|
||||
{{ termination.port_speed|humanize_speed }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Cross-Connect" %}</th>
|
||||
<td>{{ termination.xconnect_id|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Patch Panel/Port" %}</th>
|
||||
<td>{{ termination.pp_info|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ termination.description|placeholder }}</td>
|
||||
</tr>
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
BIN
netbox/translations/de/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/de/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
14808
netbox/translations/de/LC_MESSAGES/django.po
Normal file
14808
netbox/translations/de/LC_MESSAGES/django.po
Normal file
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
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
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
BIN
netbox/translations/uk/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/uk/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
14607
netbox/translations/uk/LC_MESSAGES/django.po
Normal file
14607
netbox/translations/uk/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
netbox/translations/zh/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/zh/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
14270
netbox/translations/zh/LC_MESSAGES/django.po
Normal file
14270
netbox/translations/zh/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
@ -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]
|
||||
|
53
netbox/users/migrations/0009_update_group_perms.py
Normal file
53
netbox/users/migrations/0009_update_group_perms.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user