mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Merge branch 'main' into feature
This commit is contained in:
commit
6c7a0cf2b2
@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.2.7
|
placeholder: v4.2.8
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.2.7
|
placeholder: v4.2.8
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -150,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
|
|||||||
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
|
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
tx pull
|
tx pull --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, compile these portable (`.po`) files for use in the application:
|
Then, compile these portable (`.po`) files for use in the application:
|
||||||
|
@ -33,10 +33,10 @@ To download translated strings automatically, you'll need to:
|
|||||||
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
|
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
|
||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
TX_TOKEN=$TOKEN tx pull
|
TX_TOKEN=$TOKEN tx pull --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
|
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.)
|
||||||
|
|
||||||
Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
|
Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
|
||||||
|
|
||||||
|
@ -250,7 +250,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
|||||||
|
|
||||||
* Create a Python virtual environment
|
* Create a Python virtual environment
|
||||||
* Installs all required Python packages
|
* Installs all required Python packages
|
||||||
* Run database schema migrations
|
* Run database schema migrations (skip with `--readonly`)
|
||||||
* Builds the documentation locally (for offline use)
|
* Builds the documentation locally (for offline use)
|
||||||
* Aggregate static resource files on disk
|
* Aggregate static resource files on disk
|
||||||
|
|
||||||
@ -270,6 +270,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
|
|||||||
!!! note
|
!!! note
|
||||||
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
|
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
|
||||||
|
|
||||||
## Create a Super User
|
## Create a Super User
|
||||||
|
|
||||||
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
|
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
|
||||||
|
@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||||
|
|
||||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
|
||||||
|
|
||||||
The following sections detail how to set up a new instance of NetBox:
|
The following sections detail how to set up a new instance of NetBox:
|
||||||
|
|
||||||
1. [PostgreSQL database](1-postgresql.md)
|
1. [PostgreSQL database](1-postgresql.md)
|
||||||
|
@ -122,17 +122,19 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
|||||||
|
|
||||||
### Option B: Check Out a Git Release
|
### Option B: Check Out a Git Release
|
||||||
|
|
||||||
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
|
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo git fetch --tags
|
git ls-remote --tags https://github.com/netbox-community/netbox.git \
|
||||||
git describe --tags $(git rev-list --tags --max-count=1)
|
| grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \
|
||||||
|
| tail -n 1 \
|
||||||
|
| sed 's|refs/tags/||'
|
||||||
```
|
```
|
||||||
|
|
||||||
Check out the desired release by specifying its tag:
|
Check out the desired release by specifying its tag. For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo git checkout v4.2.0
|
sudo git checkout v4.2.7
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Run the Upgrade Script
|
## 4. Run the Upgrade Script
|
||||||
@ -150,6 +152,9 @@ sudo ./upgrade.sh
|
|||||||
sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
|
sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
|
||||||
|
|
||||||
This script performs the following actions:
|
This script performs the following actions:
|
||||||
|
|
||||||
* Destroys and rebuilds the Python virtual environment
|
* Destroys and rebuilds the Python virtual environment
|
||||||
|
@ -1,5 +1,35 @@
|
|||||||
# NetBox v4.2
|
# NetBox v4.2
|
||||||
|
|
||||||
|
## v4.2.8 (2025-04-22)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script
|
||||||
|
* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view
|
||||||
|
* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN
|
||||||
|
* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists
|
||||||
|
* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form
|
||||||
|
* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view
|
||||||
|
* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config
|
||||||
|
* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug
|
||||||
|
* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model
|
||||||
|
* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API
|
||||||
|
* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views
|
||||||
|
* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields
|
||||||
|
* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces
|
||||||
|
* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members
|
||||||
|
* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices`
|
||||||
|
* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader
|
||||||
|
* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value
|
||||||
|
* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern
|
||||||
|
* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.2.7 (2025-04-10)
|
## v4.2.7 (2025-04-10)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -28,6 +28,7 @@ from netbox.config import get_config
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from users import forms, tables
|
from users import forms, tables
|
||||||
from users.models import UserConfig
|
from users.models import UserConfig
|
||||||
|
from utilities.string import remove_linebreaks
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
|
|
||||||
|
|
||||||
@ -125,12 +126,18 @@ class LoginView(View):
|
|||||||
|
|
||||||
# Set the user's preferred language (if any)
|
# Set the user's preferred language (if any)
|
||||||
if language := request.user.config.get('locale.language'):
|
if language := request.user.config.get('locale.language'):
|
||||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
response.set_cookie(
|
||||||
|
key=settings.LANGUAGE_COOKIE_NAME,
|
||||||
|
value=language,
|
||||||
|
max_age=request.session.get_expiry_age(),
|
||||||
|
secure=settings.SESSION_COOKIE_SECURE,
|
||||||
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
username = form['username'].value()
|
||||||
|
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
'form': form,
|
'form': form,
|
||||||
@ -142,10 +149,10 @@ class LoginView(View):
|
|||||||
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
||||||
logger.debug(f"Redirecting user to {redirect_url}")
|
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
|
||||||
else:
|
else:
|
||||||
if redirect_url:
|
if redirect_url:
|
||||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
|
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
|
||||||
redirect_url = reverse('home')
|
redirect_url = reverse('home')
|
||||||
|
|
||||||
return HttpResponseRedirect(redirect_url)
|
return HttpResponseRedirect(redirect_url)
|
||||||
@ -220,7 +227,12 @@ class UserConfigView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
# Set/clear language cookie
|
# Set/clear language cookie
|
||||||
if language := form.cleaned_data['locale.language']:
|
if language := form.cleaned_data['locale.language']:
|
||||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
response.set_cookie(
|
||||||
|
key=settings.LANGUAGE_COOKIE_NAME,
|
||||||
|
value=language,
|
||||||
|
max_age=request.session.get_expiry_age(),
|
||||||
|
secure=settings.SESSION_COOKIE_SECURE,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||||
|
|
||||||
|
@ -159,11 +159,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
instance,
|
instance,
|
||||||
|
omit=(CircuitTermination,),
|
||||||
extra=(
|
extra=(
|
||||||
(
|
(
|
||||||
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
|
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
|
||||||
'provider_network_id',
|
'provider_network_id',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
|
||||||
|
'provider_network_id',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -1110,6 +1110,13 @@ class DeviceFilterSet(
|
|||||||
lookup_expr='in',
|
lookup_expr='in',
|
||||||
label=_('Location (ID)'),
|
label=_('Location (ID)'),
|
||||||
)
|
)
|
||||||
|
location = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
field_name='location',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Location (slug)'),
|
||||||
|
)
|
||||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='rack',
|
field_name='rack',
|
||||||
queryset=Rack.objects.all(),
|
queryset=Rack.objects.all(),
|
||||||
@ -1739,6 +1746,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||||
|
mode = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=InterfaceModeChoices,
|
||||||
|
label=_('802.1Q Mode')
|
||||||
|
)
|
||||||
vlan_id = django_filters.CharFilter(
|
vlan_id = django_filters.CharFilter(
|
||||||
method='filter_vlan_id',
|
method='filter_vlan_id',
|
||||||
label=_('Assigned VLAN')
|
label=_('Assigned VLAN')
|
||||||
|
@ -6,7 +6,7 @@ from dcim.constants import *
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from extras.forms import LocalConfigContextFilterForm
|
from extras.forms import LocalConfigContextFilterForm
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import ASN, VRF
|
from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
@ -1356,6 +1356,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||||
|
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
@ -1427,6 +1428,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('PoE type')
|
label=_('PoE type')
|
||||||
)
|
)
|
||||||
|
mode = forms.MultipleChoiceField(
|
||||||
|
choices=InterfaceModeChoices,
|
||||||
|
required=False,
|
||||||
|
label=_('802.1Q mode')
|
||||||
|
)
|
||||||
|
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VLANTranslationPolicy.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN Translation Policy')
|
||||||
|
)
|
||||||
rf_role = forms.MultipleChoiceField(
|
rf_role = forms.MultipleChoiceField(
|
||||||
choices=WirelessRoleChoices,
|
choices=WirelessRoleChoices,
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -55,14 +55,18 @@ class ComponentCreateForm(forms.Form):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Validate that all replication fields generate an equal number of values
|
# Validate that all replication fields generate an equal number of values (or a single value)
|
||||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||||
return
|
return
|
||||||
|
|
||||||
pattern_count = len(patterns)
|
pattern_count = len(patterns)
|
||||||
for field_name in self.replication_fields:
|
for field_name in self.replication_fields:
|
||||||
value_count = len(self.cleaned_data[field_name])
|
value_count = len(self.cleaned_data[field_name])
|
||||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
if self.cleaned_data[field_name]:
|
||||||
|
if value_count == 1:
|
||||||
|
# If the field resolves to a single value (because no pattern was used), multiply it by the number
|
||||||
|
# of expected values. This allows us to reuse the same label when creating multiple components.
|
||||||
|
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
|
||||||
|
elif value_count != pattern_count:
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError({
|
||||||
field_name: _(
|
field_name: _(
|
||||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||||
@ -404,6 +408,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
|||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
query_params={
|
query_params={
|
||||||
|
'virtual_chassis_id': 'null',
|
||||||
'site_id': '$site',
|
'site_id': '$site',
|
||||||
'rack_id': '$rack',
|
'rack_id': '$rack',
|
||||||
}
|
}
|
||||||
|
@ -225,8 +225,7 @@ class CableTraceSVG:
|
|||||||
"""
|
"""
|
||||||
nodes_height = 0
|
nodes_height = 0
|
||||||
nodes = []
|
nodes = []
|
||||||
# Sort them by name to make renders more readable
|
for i, term in enumerate(terminations):
|
||||||
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
|
|
||||||
node = Node(
|
node = Node(
|
||||||
position=(offset_x + i * width, self.cursor),
|
position=(offset_x + i * width, self.cursor),
|
||||||
width=width,
|
width=width,
|
||||||
|
@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
|
|||||||
|
|
||||||
INTERFACE_FHRPGROUPS = """
|
INTERFACE_FHRPGROUPS = """
|
||||||
{% for assignment in value.all %}
|
{% for assignment in value.all %}
|
||||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
|
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -2801,6 +2801,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
locations = Location.objects.all()[:2]
|
locations = Location.objects.all()[:2]
|
||||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_rack(self):
|
def test_rack(self):
|
||||||
racks = Rack.objects.all()[:2]
|
racks = Rack.objects.all()[:2]
|
||||||
@ -4416,7 +4418,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
def test_mode(self):
|
def test_mode(self):
|
||||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_description(self):
|
def test_description(self):
|
||||||
|
@ -13,7 +13,7 @@ from django.views.generic import View
|
|||||||
|
|
||||||
from circuits.models import Circuit, CircuitTermination
|
from circuits.models import Circuit, CircuitTermination
|
||||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -236,7 +236,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
regions,
|
regions,
|
||||||
omit=(Cluster, Prefix, WirelessLAN),
|
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||||
extra=(
|
extra=(
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
@ -246,8 +246,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
).distinct(),
|
).distinct(),
|
||||||
'region_id'
|
'region_id'
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(Region),
|
||||||
|
scope_id__in=regions
|
||||||
|
).distinct(),
|
||||||
|
'region'
|
||||||
|
),
|
||||||
|
|
||||||
# Handle these relations manually to avoid erroneous filter name resolution
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(
|
||||||
|
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
|
||||||
|
'region_id'
|
||||||
|
),
|
||||||
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||||
@ -330,10 +341,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
groups,
|
groups,
|
||||||
omit=(Cluster, Prefix, WirelessLAN),
|
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||||
extra=(
|
extra=(
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
|
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
|
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
|
(
|
||||||
|
ASN.objects.restrict(request.user, 'view').filter(
|
||||||
|
sites__group__in=groups
|
||||||
|
).distinct(),
|
||||||
|
'site_group_id'),
|
||||||
|
(
|
||||||
|
VirtualMachine.objects.restrict(request.user, 'view').filter(
|
||||||
|
site__group__in=groups),
|
||||||
|
'site_group_id'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(SiteGroup),
|
||||||
|
scope_id__in=groups
|
||||||
|
).distinct(),
|
||||||
|
'site_group'
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Circuit.objects.restrict(request.user, 'view').filter(
|
Circuit.objects.restrict(request.user, 'view').filter(
|
||||||
terminations___site_group=instance
|
terminations___site_group=instance
|
||||||
@ -342,6 +372,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
),
|
),
|
||||||
|
|
||||||
# Handle these relations manually to avoid erroneous filter name resolution
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(
|
||||||
|
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||||
|
'site_group_id'
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||||
'site_group_id'
|
'site_group_id'
|
||||||
@ -444,6 +478,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
|
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -523,7 +558,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
'related_models': self.get_related_models(
|
'related_models': self.get_related_models(
|
||||||
request,
|
request,
|
||||||
locations,
|
locations,
|
||||||
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
|
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
|
||||||
extra=(
|
extra=(
|
||||||
(
|
(
|
||||||
Circuit.objects.restrict(request.user, 'view').filter(
|
Circuit.objects.restrict(request.user, 'view').filter(
|
||||||
@ -533,6 +568,10 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
),
|
),
|
||||||
|
|
||||||
# Handle these relations manually to avoid erroneous filter name resolution
|
# Handle these relations manually to avoid erroneous filter name resolution
|
||||||
|
(
|
||||||
|
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
|
||||||
|
'location_id'
|
||||||
|
),
|
||||||
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||||
@ -793,7 +832,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
omit=(CableTermination,),
|
||||||
|
extra=(
|
||||||
|
(
|
||||||
|
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(Rack),
|
||||||
|
scope_id=instance.pk
|
||||||
|
), 'rack'),
|
||||||
|
),
|
||||||
|
),
|
||||||
'next_rack': next_rack,
|
'next_rack': next_rack,
|
||||||
'prev_rack': prev_rack,
|
'prev_rack': prev_rack,
|
||||||
'svg_extra': svg_extra,
|
'svg_extra': svg_extra,
|
||||||
|
@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||||
)
|
)
|
||||||
|
|
||||||
def clean_extra_choices(self):
|
def clean_extra_choices(self):
|
||||||
|
@ -566,28 +566,23 @@ class BaseScript:
|
|||||||
def load_yaml(self, filename):
|
def load_yaml(self, filename):
|
||||||
"""
|
"""
|
||||||
Return data from a YAML file
|
Return data from a YAML file
|
||||||
TODO: DEPRECATED: Remove this method in v4.4
|
|
||||||
"""
|
"""
|
||||||
|
# TODO: DEPRECATED: Remove this method in v4.4
|
||||||
self._log(
|
self._log(
|
||||||
_("load_yaml is deprecated and will be removed in v4.4"),
|
_("load_yaml is deprecated and will be removed in v4.4"),
|
||||||
level=LogLevelChoices.LOG_WARNING
|
level=LogLevelChoices.LOG_WARNING
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
from yaml import CLoader as Loader
|
|
||||||
except ImportError:
|
|
||||||
from yaml import Loader
|
|
||||||
|
|
||||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||||
with open(file_path, 'r') as datafile:
|
with open(file_path, 'r') as datafile:
|
||||||
data = yaml.load(datafile, Loader=Loader)
|
data = yaml.load(datafile, Loader=yaml.SafeLoader)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def load_json(self, filename):
|
def load_json(self, filename):
|
||||||
"""
|
"""
|
||||||
Return data from a JSON file
|
Return data from a JSON file
|
||||||
TODO: DEPRECATED: Remove this method in v4.4
|
|
||||||
"""
|
"""
|
||||||
|
# TODO: DEPRECATED: Remove this method in v4.4
|
||||||
self._log(
|
self._log(
|
||||||
_("load_json is deprecated and will be removed in v4.4"),
|
_("load_json is deprecated and will be removed in v4.4"),
|
||||||
level=LogLevelChoices.LOG_WARNING
|
level=LogLevelChoices.LOG_WARNING
|
||||||
|
@ -351,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
|
|||||||
to_field_name='rd',
|
to_field_name='rd',
|
||||||
label=_('VRF (RD)'),
|
label=_('VRF (RD)'),
|
||||||
)
|
)
|
||||||
|
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='vlan__group',
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
to_field_name="id",
|
||||||
|
label=_('VLAN Group (ID)'),
|
||||||
|
)
|
||||||
|
vlan_group = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='vlan__group__slug',
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
to_field_name="slug",
|
||||||
|
label=_('VLAN Group (slug)'),
|
||||||
|
)
|
||||||
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
label=_('VLAN (ID)'),
|
label=_('VLAN (ID)'),
|
||||||
@ -1150,7 +1162,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class ServiceFilterSet(NetBoxModelFilterSet):
|
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||||
device = MultiValueCharFilter(
|
device = MultiValueCharFilter(
|
||||||
method='filter_device',
|
method='filter_device',
|
||||||
field_name='name',
|
field_name='name',
|
||||||
|
@ -176,7 +176,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
|
|||||||
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
||||||
name=_('Addressing')
|
name=_('Addressing')
|
||||||
),
|
),
|
||||||
FieldSet('vlan_id', name=_('VLAN Assignment')),
|
FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
|
||||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
@ -260,6 +260,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
vlan_group_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN Group'),
|
||||||
|
)
|
||||||
vlan_id = DynamicModelMultipleChoiceField(
|
vlan_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -538,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
|
|||||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||||
assigned_object=instance
|
assigned_object=instance
|
||||||
)
|
)
|
||||||
ipaddress.populate_custom_field_defaults()
|
|
||||||
ipaddress.save()
|
ipaddress.save()
|
||||||
|
|
||||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||||
|
@ -645,9 +645,16 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
vrfs[1].export_targets.add(route_targets[1])
|
vrfs[1].export_targets.add(route_targets[1])
|
||||||
vrfs[2].export_targets.add(route_targets[2])
|
vrfs[2].export_targets.add(route_targets[2])
|
||||||
|
|
||||||
|
vlan_groups = (
|
||||||
|
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||||
|
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
|
||||||
|
)
|
||||||
|
for vlan_group in vlan_groups:
|
||||||
|
vlan_group.save()
|
||||||
|
|
||||||
vlans = (
|
vlans = (
|
||||||
VLAN(vid=1, name='VLAN 1'),
|
VLAN(vid=1, name='VLAN 1', group=vlan_groups[0]),
|
||||||
VLAN(vid=2, name='VLAN 2'),
|
VLAN(vid=2, name='VLAN 2', group=vlan_groups[1]),
|
||||||
VLAN(vid=3, name='VLAN 3'),
|
VLAN(vid=3, name='VLAN 3'),
|
||||||
)
|
)
|
||||||
VLAN.objects.bulk_create(vlans)
|
VLAN.objects.bulk_create(vlans)
|
||||||
@ -850,6 +857,13 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_vlan_group(self):
|
||||||
|
vlan_groups = VLANGroup.objects.all()[:2]
|
||||||
|
params = {'vlan_group_id': [vlan_groups[0].pk, vlan_groups[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
def test_vlan(self):
|
def test_vlan(self):
|
||||||
vlans = VLAN.objects.all()[:2]
|
vlans = VLAN.objects.all()[:2]
|
||||||
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
||||||
|
@ -47,7 +47,12 @@ class CoreMiddleware:
|
|||||||
# Check if language cookie should be renewed
|
# Check if language cookie should be renewed
|
||||||
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||||
if language := request.user.config.get('locale.language'):
|
if language := request.user.config.get('locale.language'):
|
||||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
response.set_cookie(
|
||||||
|
key=settings.LANGUAGE_COOKIE_NAME,
|
||||||
|
value=language,
|
||||||
|
max_age=request.session.get_expiry_age(),
|
||||||
|
secure=settings.SESSION_COOKIE_SECURE,
|
||||||
|
)
|
||||||
|
|
||||||
# Attach the unique request ID as an HTTP header.
|
# Attach the unique request ID as an HTTP header.
|
||||||
response['X-Request-ID'] = request.id
|
response['X-Request-ID'] = request.id
|
||||||
|
@ -301,6 +301,14 @@ class CustomFieldsMixin(models.Model):
|
|||||||
if cf.required and cf.name not in self.custom_field_data:
|
if cf.required and cf.name not in self.custom_field_data:
|
||||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Populate default values if omitted
|
||||||
|
for cf in self.custom_fields.filter(default__isnull=False):
|
||||||
|
if cf.name not in self.custom_field_data:
|
||||||
|
self.custom_field_data[cf.name] = cf.default
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinksMixin(models.Model):
|
class CustomLinksMixin(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -9,8 +9,7 @@ const options = {
|
|||||||
outdir: './dist',
|
outdir: './dist',
|
||||||
bundle: true,
|
bundle: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: 'external',
|
sourcemap: 'linked',
|
||||||
sourcesContent: false,
|
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -30,7 +30,7 @@
|
|||||||
"gridstack": "12.0.0",
|
"gridstack": "12.0.0",
|
||||||
"htmx.org": "2.0.4",
|
"htmx.org": "2.0.4",
|
||||||
"query-string": "9.1.1",
|
"query-string": "9.1.1",
|
||||||
"sass": "1.86.3",
|
"sass": "1.87.0",
|
||||||
"tom-select": "2.4.3",
|
"tom-select": "2.4.3",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
"typeface-roboto-mono": "1.1.13"
|
"typeface-roboto-mono": "1.1.13"
|
||||||
|
@ -5,11 +5,13 @@ interface PluginConfig {
|
|||||||
export function getPlugins(element: HTMLSelectElement): object {
|
export function getPlugins(element: HTMLSelectElement): object {
|
||||||
const plugins: PluginConfig = {};
|
const plugins: PluginConfig = {};
|
||||||
|
|
||||||
// Enable "clear all" button
|
// Enable "clear all" button for non-required fields
|
||||||
|
if (!element.required) {
|
||||||
plugins.clear_button = {
|
plugins.clear_button = {
|
||||||
html: (data: Dict) =>
|
html: (data: Dict) =>
|
||||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Enable individual "remove" buttons for items on multi-select fields
|
// Enable individual "remove" buttons for items on multi-select fields
|
||||||
if (element.hasAttribute('multiple')) {
|
if (element.hasAttribute('multiple')) {
|
||||||
|
@ -3,6 +3,12 @@ html {
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove horizontal padding from highlighted text
|
||||||
|
mark {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent dropdown menus from being clipped inside responsive tables
|
// Prevent dropdown menus from being clipped inside responsive tables
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
.dropdown, .btn-group, .btn-group-vertical {
|
.dropdown, .btn-group, .btn-group-vertical {
|
||||||
|
@ -2678,10 +2678,10 @@ safe-regex-test@^1.0.3:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-regex "^1.1.4"
|
is-regex "^1.1.4"
|
||||||
|
|
||||||
sass@1.86.3:
|
sass@1.87.0:
|
||||||
version "1.86.3"
|
version "1.87.0"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.3.tgz#0a0d9ea97cb6665e73f409639f8533ce057464c9"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e"
|
||||||
integrity sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==
|
integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar "^4.0.0"
|
chokidar "^4.0.0"
|
||||||
immutable "^5.0.2"
|
immutable "^5.0.2"
|
||||||
|
@ -3,7 +3,9 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
{% render_errors form %}
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -3,6 +3,9 @@
|
|||||||
{% load form_helpers %}
|
{% load form_helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{# A side termination #}
|
{# A side termination #}
|
||||||
<div class="field-group mb-5">
|
<div class="field-group mb-5">
|
||||||
|
@ -20,9 +20,14 @@
|
|||||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for term in terminations %}
|
{% for term in terminations %}
|
||||||
{{term.device|linkify}}
|
{{ term.device|linkify }}
|
||||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||||
{{ term|linkify }}
|
{{ term|linkify }}
|
||||||
|
{% with trace_url=term|viewname:"trace" %}
|
||||||
|
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||||
|
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
{% endwith %}
|
||||||
{% if not forloop.last %}<br/>{% endif %}
|
{% if not forloop.last %}<br/>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
@ -41,7 +46,13 @@
|
|||||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for term in terminations %}
|
{% for term in terminations %}
|
||||||
{{ term|linkify }}{% if not forloop.last %},{% endif %}
|
{{ term|linkify }}
|
||||||
|
{% with trace_url=term|viewname:"trace" %}
|
||||||
|
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||||
|
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
{% endwith %}
|
||||||
|
{% if not forloop.last %}<br/>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -55,7 +66,13 @@
|
|||||||
<th scope="row">{% trans "Circuit" %}</th>
|
<th scope="row">{% trans "Circuit" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% for term in terminations %}
|
{% for term in terminations %}
|
||||||
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
|
{{ term.circuit|linkify }} ({{ term }})
|
||||||
|
{% with trace_url=term|viewname:"trace" %}
|
||||||
|
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||||
|
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
{% endwith %}
|
||||||
|
{% if not forloop.last %}<br/>{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -3,6 +3,10 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
|
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
|
||||||
|
@ -12,11 +12,15 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
|
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
|
||||||
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
||||||
|
{% render_errors vc_form %}
|
||||||
{% for form in formset %}
|
{% for form in formset %}
|
||||||
{% render_errors form %}
|
{% render_errors form %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% for field in vc_form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
{{ pk_form.pk }}
|
{{ pk_form.pk }}
|
||||||
{{ formset.management_form }}
|
{{ formset.management_form }}
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}"{% if widget.required %} required{% endif %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||||
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
||||||
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
||||||
</optgroup>{% endif %}{% endfor %}
|
</optgroup>{% endif %}{% endfor %}
|
||||||
|
@ -54,11 +54,14 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-header d-flex justify-content-between">
|
<h2 class="card-header d-flex justify-content-between">
|
||||||
{% trans "Rendered Config" %}
|
{% trans "Rendered Config" %}
|
||||||
|
<div>
|
||||||
|
{% copy_content "rendered_config" %}
|
||||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<pre class="card-body">{{ rendered_config }}</pre>
|
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
@ -5,6 +5,10 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<div class="field-group my-5">
|
<div class="field-group my-5">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2 class="col-9 offset-3">{% trans "VLAN" %}</h2>
|
<h2 class="col-9 offset-3">{% trans "VLAN" %}</h2>
|
||||||
|
@ -81,6 +81,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'inc/panels/related_objects.html' %}
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
{% include 'inc/panels/tags.html' %}
|
{% include 'inc/panels/tags.html' %}
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
|
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
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
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
@ -2,6 +2,7 @@ from urllib.parse import urlencode
|
|||||||
|
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.utils.datastructures import MultiValueDict
|
from django.utils.datastructures import MultiValueDict
|
||||||
|
from netbox.models import CloningMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'dict_to_querydict',
|
'dict_to_querydict',
|
||||||
@ -46,7 +47,7 @@ def prepare_cloned_fields(instance):
|
|||||||
Generate a QueryDict comprising attributes from an object's clone() method.
|
Generate a QueryDict comprising attributes from an object's clone() method.
|
||||||
"""
|
"""
|
||||||
# Generate the clone attributes from the instance
|
# Generate the clone attributes from the instance
|
||||||
if not hasattr(instance, 'clone'):
|
if not issubclass(type(instance), CloningMixin):
|
||||||
return QueryDict(mutable=True)
|
return QueryDict(mutable=True)
|
||||||
attrs = instance.clone()
|
attrs = instance.clone()
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import re
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'enum_key',
|
'enum_key',
|
||||||
|
'remove_linebreaks',
|
||||||
'title',
|
'title',
|
||||||
'trailing_slash',
|
'trailing_slash',
|
||||||
)
|
)
|
||||||
@ -15,6 +16,13 @@ def enum_key(value):
|
|||||||
return re.sub(r'[^_A-Z0-9]', '_', value)
|
return re.sub(r'[^_A-Z0-9]', '_', value)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_linebreaks(value):
|
||||||
|
"""
|
||||||
|
Remove all line breaks from a string and return the result. Useful for log sanitization purposes.
|
||||||
|
"""
|
||||||
|
return value.replace('\n', '').replace('\r', '')
|
||||||
|
|
||||||
|
|
||||||
def title(value):
|
def title(value):
|
||||||
"""
|
"""
|
||||||
Improved implementation of str.title(); retains all existing uppercase letters.
|
Improved implementation of str.title(); retains all existing uppercase letters.
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from dcim.choices import *
|
||||||
from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.forms import LocalConfigContextFilterForm
|
from extras.forms import LocalConfigContextFilterForm
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import VRF
|
from ipam.models import VRF, VLANTranslationPolicy
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
|
||||||
@ -200,7 +201,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
|
FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
|
||||||
FieldSet('enabled', 'mac_address', 'vrf_id', 'l2vpn_id', name=_('Attributes')),
|
FieldSet('enabled', name=_('Attributes')),
|
||||||
|
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')),
|
||||||
|
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||||
)
|
)
|
||||||
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
||||||
cluster_id = DynamicModelMultipleChoiceField(
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
@ -237,6 +240,16 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('L2VPN')
|
label=_('L2VPN')
|
||||||
)
|
)
|
||||||
|
mode = forms.MultipleChoiceField(
|
||||||
|
choices=InterfaceModeChoices,
|
||||||
|
required=False,
|
||||||
|
label=_('802.1Q mode')
|
||||||
|
)
|
||||||
|
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VLANTranslationPolicy.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN Translation Policy')
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -606,6 +606,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
mtu=100,
|
mtu=100,
|
||||||
vrf=vrfs[0],
|
vrf=vrfs[0],
|
||||||
description='foobar1',
|
description='foobar1',
|
||||||
|
mode=InterfaceModeChoices.MODE_ACCESS,
|
||||||
vlan_translation_policy=vlan_translation_policies[0],
|
vlan_translation_policy=vlan_translation_policies[0],
|
||||||
),
|
),
|
||||||
VMInterface(
|
VMInterface(
|
||||||
@ -615,6 +616,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
mtu=200,
|
mtu=200,
|
||||||
vrf=vrfs[1],
|
vrf=vrfs[1],
|
||||||
description='foobar2',
|
description='foobar2',
|
||||||
|
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||||
vlan_translation_policy=vlan_translation_policies[0],
|
vlan_translation_policy=vlan_translation_policies[0],
|
||||||
),
|
),
|
||||||
VMInterface(
|
VMInterface(
|
||||||
@ -700,6 +702,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'description': ['foobar1', 'foobar2']}
|
params = {'description': ['foobar1', 'foobar2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_mode(self):
|
||||||
|
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_vlan(self):
|
def test_vlan(self):
|
||||||
vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
|
vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
|
||||||
params = {'vlan_id': vlan.pk}
|
params = {'vlan_id': vlan.pk}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Prefetch, Sum
|
from django.db.models import Prefetch, Sum
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@ -10,7 +11,7 @@ from dcim.forms import DeviceFilterForm
|
|||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from dcim.tables import DeviceTable
|
from dcim.tables import DeviceTable
|
||||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress, VLANGroup
|
||||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -102,7 +103,17 @@ class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
|||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
return {
|
return {
|
||||||
'related_models': self.get_related_models(request, instance),
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
extra=(
|
||||||
|
(
|
||||||
|
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(ClusterGroup),
|
||||||
|
scope_id=instance.pk
|
||||||
|
), 'cluster_group'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -162,15 +173,28 @@ class ClusterListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Cluster)
|
@register_model_view(Cluster)
|
||||||
class ClusterView(generic.ObjectView):
|
class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Cluster.objects.all()
|
queryset = Cluster.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
return instance.virtual_machines.aggregate(
|
return {
|
||||||
|
**instance.virtual_machines.aggregate(
|
||||||
vcpus_sum=Sum('vcpus'),
|
vcpus_sum=Sum('vcpus'),
|
||||||
memory_sum=Sum('memory'),
|
memory_sum=Sum('memory'),
|
||||||
disk_sum=Sum('disk')
|
disk_sum=Sum('disk')
|
||||||
|
),
|
||||||
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
omit=(),
|
||||||
|
extra=(
|
||||||
|
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(Cluster),
|
||||||
|
scope_id=instance.pk
|
||||||
|
), 'cluster'),
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
|
@register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
|
||||||
|
@ -22,7 +22,7 @@ gunicorn==23.0.0
|
|||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
jsonschema==4.23.0
|
jsonschema==4.23.0
|
||||||
Markdown==3.8
|
Markdown==3.8
|
||||||
mkdocs-material==9.6.11
|
mkdocs-material==9.6.12
|
||||||
mkdocstrings[python]==0.29.1
|
mkdocstrings[python]==0.29.1
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.2.21
|
nh3==0.2.21
|
||||||
@ -33,7 +33,7 @@ requests==2.32.3
|
|||||||
rq==2.3.2
|
rq==2.3.2
|
||||||
social-auth-app-django==5.4.3
|
social-auth-app-django==5.4.3
|
||||||
social-auth-core==4.5.6
|
social-auth-core==4.5.6
|
||||||
strawberry-graphql==0.264.0
|
strawberry-graphql==0.266.0
|
||||||
strawberry-graphql-django==0.58.0
|
strawberry-graphql-django==0.58.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.8.0
|
tablib==3.8.0
|
||||||
|
18
upgrade.sh
18
upgrade.sh
@ -6,6 +6,13 @@
|
|||||||
# variable (if set), or fall back to "python3". Note that NetBox v4.0+ requires
|
# variable (if set), or fall back to "python3". Note that NetBox v4.0+ requires
|
||||||
# Python 3.10 or later.
|
# Python 3.10 or later.
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
if [[ "$1" == "--readonly" ]]; then
|
||||||
|
READONLY_MODE=true
|
||||||
|
else
|
||||||
|
READONLY_MODE=false
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
NETBOX_VERSION="$(grep ^version netbox/release.yaml | cut -d \" -f2)"
|
NETBOX_VERSION="$(grep ^version netbox/release.yaml | cut -d \" -f2)"
|
||||||
@ -83,9 +90,14 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Apply any database migrations
|
# Apply any database migrations
|
||||||
COMMAND="python3 netbox/manage.py migrate"
|
if [ "$READONLY_MODE" = true ]; then
|
||||||
echo "Applying database migrations ($COMMAND)..."
|
echo "Skipping database migrations (read-only mode)"
|
||||||
eval $COMMAND || exit 1
|
exit 0
|
||||||
|
else
|
||||||
|
COMMAND="python3 netbox/manage.py migrate"
|
||||||
|
echo "Applying database migrations ($COMMAND)..."
|
||||||
|
eval $COMMAND || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Trace any missing cable paths (not typically needed)
|
# Trace any missing cable paths (not typically needed)
|
||||||
COMMAND="python3 netbox/manage.py trace_paths --no-input"
|
COMMAND="python3 netbox/manage.py trace_paths --no-input"
|
||||||
|
Loading…
Reference in New Issue
Block a user