Merge pull request #12681 from netbox-community/develop

Release v3.5.2
This commit is contained in:
Jeremy Stretch 2023-05-22 16:54:03 -04:00 committed by GitHub
commit c9b79ca579
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 708 additions and 183 deletions

View File

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

View File

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

View File

@ -1,11 +1,13 @@
<div align="center"> <div align="center">
<strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
<p>Please take a few minutes to tell us about your NetBox deployment.</p>
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
The premiere source of truth powering network automation <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div> </div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions, datacenter infrastructure management (DCIM) with powerful APIs and extensions,

View File

@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
``` ```
>>> lab1 = Site.objects.get(pk=7) >>> lab1 = Site.objects.get(pk=7)
>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1) >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
>>> myvlan.full_clean()
>>> myvlan.save() >>> myvlan.save()
``` ```
Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
```
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
```
To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again. To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
``` ```
@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
>>> vlan.name >>> vlan.name
'MyNewVLAN' 'MyNewVLAN'
>>> vlan.name = 'BetterName' >>> vlan.name = 'BetterName'
>>> vlan.full_clean()
>>> vlan.save() >>> vlan.save()
>>> VLAN.objects.get(pk=1280).name >>> VLAN.objects.get(pk=1280).name
'BetterName' 'BetterName'

View File

@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
--- ---
## BANNER_MAINTENANCE
!!! tip "Dynamic Configuration Parameter"
!!! note
This parameter was added in NetBox v3.5.
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
---
## BANNER_TOP ## BANNER_TOP
!!! tip "Dynamic Configuration Parameter" !!! tip "Dynamic Configuration Parameter"
@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
Default: `https://maps.google.com/?q=` (Google Maps) Default: `https://maps.google.com/?q=` (Google Maps)
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
--- ---
@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300` Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds. The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
--- ---
## REMOTE_AUTH_AUTO_CREATE_GROUPS
Default: `False`
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_AUTO_CREATE_USER ## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False` Default: `False`

View File

@ -378,6 +378,7 @@ class NewBranchScript(Script):
slug=slugify(data['site_name']), slug=slugify(data['site_name']),
status=SiteStatusChoices.STATUS_PLANNED status=SiteStatusChoices.STATUS_PLANNED
) )
site.full_clean()
site.save() site.save()
self.log_success(f"Created new site: {site}") self.log_success(f"Created new site: {site}")
@ -391,6 +392,7 @@ class NewBranchScript(Script):
status=DeviceStatusChoices.STATUS_PLANNED, status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role device_role=switch_role
) )
switch.full_clean()
switch.save() switch.save()
self.log_success(f"Created new switch: {switch}") self.log_success(f"Created new switch: {switch}")

View File

@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
``` ```
sudo adduser --system --group netbox sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
``` ```
=== "CentOS" === "CentOS"
@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
sudo groupadd --system netbox sudo groupadd --system netbox
sudo adduser --system -g netbox netbox sudo adduser --system -g netbox netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
``` ```
## Configuration ## Configuration

View File

@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
On CentOS: On CentOS:
```no-highlight ```no-highlight
sudo yum install -y openldap-devel sudo yum install -y openldap-devel python3-devel
``` ```
### Install django-auth-ldap ### Install django-auth-ldap

View File

@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
## Interactive Documentation ## Interactive Documentation
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
## Endpoint Hierarchy ## Endpoint Hierarchy

View File

@ -1,5 +1,41 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.2 (2023-05-22)
### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
---
## v3.5.1 (2023-05-05) ## v3.5.1 (2023-05-05)
### Enhancements ### Enhancements

View File

@ -1,10 +1,10 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from dcim.views import PathTraceView from dcim.views import PathTraceView
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import register_model_view from utilities.views import register_model_view
@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable table = tables.ProviderTable
@register_model_view(Provider, 'contacts')
class ProviderContactsView(ObjectContactsView):
queryset = Provider.objects.all()
# #
# ProviderAccounts # ProviderAccounts
# #
@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderAccountTable table = tables.ProviderAccountTable
@register_model_view(ProviderAccount, 'contacts')
class ProviderAccountContactsView(ObjectContactsView):
queryset = ProviderAccount.objects.all()
# #
# Provider networks # Provider networks
# #
@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
}) })
@register_model_view(Circuit, 'contacts')
class CircuitContactsView(ObjectContactsView):
queryset = Circuit.objects.all()
# #
# Circuit terminations # Circuit terminations
# #

View File

@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model, get_rq_retry
__all__ = ( __all__ = (
'Job', 'Job',
@ -219,5 +219,6 @@ class Job(models.Model):
event=event, event=event,
data=self.data, data=self.data,
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=self.user.username username=self.user.username,
retry=get_rq_retry()
) )

View File

@ -493,7 +493,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
'vdcs',
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet

View File

@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP = '100gbase-x-cfp' TYPE_100GE_CFP = '100gbase-x-cfp'
TYPE_100GE_CFP2 = '100gbase-x-cfp2' TYPE_100GE_CFP2 = '100gbase-x-cfp2'
TYPE_100GE_CFP4 = '100gbase-x-cfp4' TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp' TYPE_800GE_OSFP = '800gbase-x-osfp'
@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'),
) )
@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
TYPE_SN = 'sn' TYPE_SN = 'sn'
@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'), (TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'), (TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'), (TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm(
break break
if site is not None: if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) # Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True

View File

@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
required=False, required=False,
help_text=_('The default platform for devices of this type (optional)') help_text=_('The default platform for devices of this type (optional)')
) )
weight = forms.DecimalField(
required=False,
help_text=_('Device weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for device weight')
)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'comments', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
] ]
@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name' to_field_name='name'
) )
weight = forms.DecimalField(
required=False,
help_text=_('Module weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for module weight')
)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
class DeviceRoleImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm):
@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
model = content_type.model_class() model = content_type.model_class()
try: try:
termination_object = model.objects.get(device=device, name=name) if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None: if termination_object.cable is not None:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist: except ObjectDoesNotExist:

View File

@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField( installed_device = forms.ModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Child Device'), label=_('Child Device'),
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.") help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
) )
def __init__(self, device_bay, *args, **kwargs): def __init__(self, device_bay, *args, **kwargs):

View File

@ -242,6 +242,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
choices=[], choices=[],
label=_('Rear ports'), label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'), help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
) )
# Override fieldsets from FrontPortForm to omit rear_port_position # Override fieldsets from FrontPortForm to omit rear_port_position

View File

@ -0,0 +1,42 @@
# Generated by Django 4.1.9 on 2023-05-12 18:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0171_cabletermination_change_logging'),
]
operations = [
migrations.AlterField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
]

View File

@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
blank=True blank=True
) )
maximum_draw = models.PositiveSmallIntegerField( maximum_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_("Maximum power draw (watts)")
) )
allocated_draw = models.PositiveSmallIntegerField( allocated_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],

View File

@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
blank=True, blank=True,
help_text=_('Physical port type') help_text=_('Physical port type')
) )
maximum_draw = models.PositiveSmallIntegerField( maximum_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_("Maximum power draw (watts)")
) )
allocated_draw = models.PositiveSmallIntegerField( allocated_draw = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],

View File

@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
'subdevice_role': self.subdevice_role, 'subdevice_role': self.subdevice_role,
'airflow': self.airflow, 'airflow': self.airflow,
'comments': self.comments, 'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
} }
# Component templates # Component templates
@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
'model': self.model, 'model': self.model,
'part_number': self.part_number, 'part_number': self.part_number,
'comments': self.comments, 'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
} }
# Component templates # Component templates

View File

@ -22,6 +22,11 @@ __all__ = (
'RackElevationSVG', 'RackElevationSVG',
) )
GRADIENT_RESERVED = '#b0b0ff'
GRADIENT_OCCUPIED = '#d7d7d7'
GRADIENT_BLOCKED = '#ffc0c0'
STROKE_RESERVED = '#4d4dff'
def get_device_name(device): def get_device_name(device):
if device.virtual_chassis: if device.virtual_chassis:
@ -132,9 +137,9 @@ class RackElevationSVG:
drawing.defs.add(drawing.style(css_file.read())) drawing.defs.add(drawing.style(css_file.read()))
# Add gradients # Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
return drawing return drawing
@ -246,13 +251,13 @@ class RackElevationSVG:
coords = self._get_device_coords(segment[0], u_height) coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = ( size = (
self.margin_width, self.margin_width - 3,
u_height * self.unit_height u_height * self.unit_height
) )
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent') link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add( link.add(
Rect(coords, size, class_='reservation') Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
) )
self.drawing.add(link) self.drawing.add(link)

View File

@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
config_template = tables.Column( config_template = tables.Column(
linkify=True linkify=True
) )
parent_device = tables.Column(
verbose_name='Parent Device',
linkify=True,
accessor='parent_bay__device'
)
device_bay_position = tables.Column(
verbose_name='Position (Device Bay)',
accessor='parent_bay',
linkify=True
)
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:device_list' url_name='dcim:device_list'
@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = models.Device model = models.Device
fields = ( fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -681,11 +681,15 @@ class DeviceTypeTestCase(
""" """
IMPORT_DATA = """ IMPORT_DATA = """
manufacturer: Generic manufacturer: Generic
default_platform: Platform
model: TEST-1000 model: TEST-1000
slug: test-1000 slug: test-1000
default_platform: Platform
u_height: 2 u_height: 2
is_full_depth: false
airflow: front-to-rear
subdevice_role: parent subdevice_role: parent
weight: 10
weight_unit: kg
comments: Test comment comments: Test comment
console-ports: console-ports:
- name: Console Port 1 - name: Console Port 1
@ -794,8 +798,16 @@ inventory-items:
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
device_type = DeviceType.objects.get(model='TEST-1000') device_type = DeviceType.objects.get(model='TEST-1000')
self.assertEqual(device_type.comments, 'Test comment') self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(device_type.default_platform.pk, platform.pk) self.assertEqual(device_type.default_platform.pk, platform.pk)
self.assertEqual(device_type.slug, 'test-1000')
self.assertEqual(device_type.u_height, 2)
self.assertFalse(device_type.is_full_depth)
self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
self.assertEqual(device_type.weight, 10)
self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
self.assertEqual(device_type.comments, 'Test comment')
# Verify all of the components were created # Verify all of the components were created
self.assertEqual(device_type.consoleporttemplates.count(), 3) self.assertEqual(device_type.consoleporttemplates.count(), 3)
@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
IMPORT_DATA = """ IMPORT_DATA = """
manufacturer: Generic manufacturer: Generic
model: TEST-1000 model: TEST-1000
weight: 10
weight_unit: lb
comments: Test comment comments: Test comment
console-ports: console-ports:
- name: Console Port 1 - name: Console Port 1
@ -1082,7 +1096,8 @@ front-ports:
""" """
# Create the manufacturer # Create the manufacturer
Manufacturer(name='Generic', slug='generic').save() manufacturer = Manufacturer(name='Generic', slug='generic')
manufacturer.save()
# Add all required permissions to the test user # Add all required permissions to the test user
self.add_permissions( self.add_permissions(
@ -1105,6 +1120,9 @@ front-ports:
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
module_type = ModuleType.objects.get(model='TEST-1000') module_type = ModuleType.objects.get(model='TEST-1000')
self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(module_type.weight, 10)
self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
self.assertEqual(module_type.comments, 'Test comment') self.assertEqual(module_type.comments, 'Test comment')
# Verify all the components were created # Verify all the components were created
@ -2889,6 +2907,7 @@ class CableTestCase(
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
devices = ( devices = (
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@ -2898,6 +2917,10 @@ class CableTestCase(
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
vc.members.set((devices[0], devices[1], devices[2]))
vc.master = devices[0]
vc.save()
interfaces = ( interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@ -2911,6 +2934,10 @@ class CableTestCase(
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
) )
Interface.objects.bulk_create(interfaces) Interface.objects.bulk_create(interfaces)
@ -2943,6 +2970,8 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable from ipam.tables import InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable table = tables.RegionTable
@register_model_view(Region, 'contacts')
class RegionContactsView(ObjectContactsView):
queryset = Region.objects.all()
# #
# Site groups # Site groups
# #
@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteGroupTable table = tables.SiteGroupTable
@register_model_view(SiteGroup, 'contacts')
class SiteGroupContactsView(ObjectContactsView):
queryset = SiteGroup.objects.all()
# #
# Sites # Sites
# #
@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteTable table = tables.SiteTable
@register_model_view(Site, 'contacts')
class SiteContactsView(ObjectContactsView):
queryset = Site.objects.all()
# #
# Locations # Locations
# #
@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
table = tables.LocationTable table = tables.LocationTable
@register_model_view(Location, 'contacts')
class LocationContactsView(ObjectContactsView):
queryset = Location.objects.all()
# #
# Rack roles # Rack roles
# #
@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
table = tables.RackTable table = tables.RackTable
@register_model_view(Rack, 'contacts')
class RackContactsView(ObjectContactsView):
queryset = Rack.objects.all()
# #
# Rack reservations # Rack reservations
# #
@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
table = tables.ManufacturerTable table = tables.ManufacturerTable
@register_model_view(Manufacturer, 'contacts')
class ManufacturerContactsView(ObjectContactsView):
queryset = Manufacturer.objects.all()
# #
# Device types # Device types
# #
@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable table = tables.DeviceTable
@register_model_view(Device, 'contacts')
class DeviceContactsView(ObjectContactsView):
queryset = Device.objects.all()
# #
# Modules # Modules
# #
@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
table = tables.PowerPanelTable table = tables.PowerPanelTable
@register_model_view(PowerPanel, 'contacts')
class PowerPanelContactsView(ObjectContactsView):
queryset = PowerPanel.objects.all()
# #
# Power feeds # Power feeds
# #

View File

@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('ALLOWED_URL_SCHEMES',), 'fields': ('ALLOWED_URL_SCHEMES',),
}), }),
('Banners', { ('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',), 'classes': ('monospace',),
}), }),
('Pagination', { ('Pagination', {

View File

@ -35,7 +35,8 @@ def get_content_type_labels():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter( for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model') ).order_by('app_label', 'model')
] ]

View File

@ -13,6 +13,22 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='customfield', model_name='customfield',
name='name', name='name',
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]), field=models.CharField(
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
message='Only alphanumeric characters and underscores are allowed.',
regex='^[a-z0-9_]+$',
),
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
inverse_match=True,
message='Double underscores are not permitted in custom field names.',
regex=r'__',
),
],
),
), ),
] ]

View File

@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
message="Only alphanumeric characters and underscores are allowed.", message="Only alphanumeric characters and underscores are allowed.",
flags=re.IGNORECASE flags=re.IGNORECASE
), ),
RegexValidator(
regex=r'__',
message="Double underscores are not permitted in custom field names.",
flags=re.IGNORECASE,
inverse_match=True
),
) )
) )
label = models.CharField( label = models.CharField(

View File

@ -73,6 +73,7 @@ class ExportTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
@ -218,6 +219,7 @@ class ConfigContextTable(NetBoxTable):
verbose_name='Active' verbose_name='Active'
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
@ -242,6 +244,7 @@ class ConfigTemplateTable(NetBoxTable):
linkify=True linkify=True
) )
is_synced = columns.BooleanColumn( is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced' verbose_name='Synced'
) )
tags = columns.TagColumn( tags = columns.TagColumn(

View File

@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
cls.object_type = ContentType.objects.get_for_model(Site) cls.object_type = ContentType.objects.get_for_model(Site)
def test_invalid_name(self):
"""
Try creating a CustomField with an invalid name.
"""
with self.assertRaises(ValidationError):
# Invalid character
CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
with self.assertRaises(ValidationError):
# Double underscores not permitted
CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
def test_text_field(self): def test_text_field(self):
value = 'Foobar!' value = 'Foobar!'

View File

@ -9,6 +9,7 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry from netbox.registry import registry
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .choices import * from .choices import *
from .models import Webhook from .models import Webhook
@ -116,5 +117,6 @@ def flush_webhooks(queue):
snapshots=data['snapshots'], snapshots=data['snapshots'],
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=data['username'], username=data['username'],
request_id=data['request_id'] request_id=data['request_id'],
retry=get_rq_retry()
) )

View File

@ -351,6 +351,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
) )
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = f"{address} is a network ID, which may not be assigned to an interface."
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.ip == address.broadcast:
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
raise ValidationError(msg)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)

View File

@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
if available_ips: if available_ips:
return next(iter(available_ips)) return next(iter(available_ips))
def get_related_ips(self):
"""
Return all IPAddresses belonging to the same VRF.
"""
return IPAddress.objects.exclude(address=str(self.address)).filter(
vrf=self.vrf, address__net_contained_or_equal=str(self.address)
)
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -9,6 +9,7 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
@ -755,19 +756,9 @@ class IPAddressView(generic.ObjectView):
# Limit to a maximum of 10 duplicates displayed here # Limit to a maximum of 10 duplicates displayed here
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False) duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
address=str(instance.address)
).filter(
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
)
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request)
return { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table, 'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
} }
@ -872,6 +863,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
table = tables.IPAddressTable table = tables.IPAddressTable
@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses')
class IPAddressRelatedIPsView(generic.ObjectChildrenView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/ipaddress/ip_addresses.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
weight=500,
hide_if_empty=True,
)
def get_children(self, request, parent):
return parent.get_related_ips().restrict(request.user, 'view')
# #
# VLAN groups # VLAN groups
# #
@ -1292,6 +1301,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
table = tables.L2VPNTable table = tables.L2VPNTable
@register_model_view(L2VPN, 'contacts')
class L2VPNContactsView(ObjectContactsView):
queryset = L2VPN.objects.all()
# #
# L2VPN terminations # L2VPN terminations
# #

View File

@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
try: try:
group_list.append(Group.objects.get(name=name)) group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist: except Group.DoesNotExist:
logging.error( if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") group_list.append(Group.objects.create(name=name))
else:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list: if group_list:
user.groups.set(group_list) user.groups.set(group_list)
logger.debug( logger.debug(

View File

@ -28,6 +28,17 @@ PARAMS = (
), ),
}, },
), ),
ConfigParam(
name='BANNER_MAINTENANCE',
label=_('Maintenance banner'),
default='NetBox is currently in maintenance mode. Functionality may be limited.',
description=_('Additional content to display when in maintenance mode'),
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
),
ConfigParam( ConfigParam(
name='BANNER_TOP', name='BANNER_TOP',
label=_('Top banner'), label=_('Top banner'),

View File

@ -3,19 +3,21 @@ import uuid
from urllib import parse from urllib import parse
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth, messages
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError from django.db import connection, ProgrammingError
from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from extras.context_managers import change_logging from extras.context_managers import change_logging
from netbox.config import clear_config from netbox.config import clear_config, get_config
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request, rest_api_server_error from utilities.api import is_api_request, rest_api_server_error
__all__ = ( __all__ = (
'CoreMiddleware', 'CoreMiddleware',
'MaintenanceModeMiddleware',
'RemoteUserMiddleware', 'RemoteUserMiddleware',
) )
@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
groups = [] groups = []
logger.debug(f"Groups are {groups}") logger.debug(f"Groups are {groups}")
return groups return groups
class MaintenanceModeMiddleware:
"""
Middleware that checks if the application is in maintenance mode
and restricts write-related operations to the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if get_config().MAINTENANCE_MODE:
self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request)
@staticmethod
def _set_session_type(allow_write):
"""
Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
"""
with connection.cursor() as cursor:
mode = 'READ WRITE' if allow_write else 'READ ONLY'
cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
def process_exception(self, request, exception):
"""
Prevent any write-related database operations if an exception is raised.
"""
if isinstance(exception, InternalError):
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
'operations. Please try again later.'
if is_api_request(request):
return rest_api_server_error(request, error=error_message)
messages.error(request, error_message)
return HttpResponseRedirect(request.path_info)

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.5.1' VERSION = '3.5.2'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@ -382,6 +385,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware', 'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware',
] ]
@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
f'/{BASE_PATH}metrics', f'/{BASE_PATH}metrics',
) )
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
)
SERIALIZATION_MODULES = { SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json', 'json': 'utilities.serializers.json',
} }

View File

@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
list(new_user.groups.all()) list(new_user.groups.all())
) )
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_GROUPS=True,
LOGIN_REQUIRED=True,
)
def test_remote_auth_remote_groups_autocreate(self):
"""
Test enabling remote authentication with group sync and autocreate
enabled with the default configuration.
"""
headers = {
"HTTP_REMOTE_USER": "remoteuser2",
"HTTP_REMOTE_USER_GROUP": "Group 1|Group 2",
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER")
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP")
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|")
groups = (
Group(name="Group 1"),
Group(name="Group 2"),
)
response = self.client.get(reverse("home"), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username="remoteuser2")
self.assertEqual(
int(self.client.session.get("_auth_user_id")),
new_user.pk,
msg="Authentication failed",
)
self.assertListEqual(
[group.name for group in groups],
[group.name for group in list(new_user.groups.all())],
)
@override_settings( @override_settings(
REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True, REMOTE_AUTH_AUTO_CREATE_USER=True,

View File

@ -77,10 +77,10 @@ Blocks:
</div> </div>
{% endif %} {% endif %}
{% if config.MAINTENANCE_MODE %} {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
<div class="alert alert-warning text-center mx-3" role="alert"> <div class="alert alert-warning text-center mx-3" role="alert">
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5> <h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
NetBox is currently in maintenance mode. Functionality may be limited. {{ config.BANNER_MAINTENANCE|escape }}
</div> </div>
{% endif %} {% endif %}

View File

@ -70,7 +70,6 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -132,9 +132,16 @@
</tr> </tr>
{% for field, value in fields.items %} {% for field, value in fields.items %}
<tr> <tr>
<td> <th scope="row">{{ field }}
<span title="{{ field.description|escape }}">{{ field }}</span> {% if field.description %}
</td> <i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td> <td>
{% customfield_value field value %} {% customfield_value field value %}
</td> </td>

View File

@ -43,7 +43,6 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -38,7 +38,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
<div class="col col-md-12"> <div class="col col-md-12">

View File

@ -298,8 +298,30 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">Dimensions</h5>
<div class="card-body table-responsive">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Height</th>
<td>
{{ object.device_type.u_height }}U
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>
{% if object.total_weight %}
{{ object.total_weight|floatformat }} Kilograms
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% if object.rack and object.position %} {% if object.rack and object.position %}
<div class="row" style="margin-bottom: 20px"> <div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center"> <div class="col col-md-6 col-sm-6 col-xs-12 text-center">

View File

@ -123,11 +123,11 @@
<table class="table table-hover"> <table class="table table-hover">
<tr> <tr>
<th scope="row">MAC Address</th> <th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td> <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">WWN</th> <th scope="row">WWN</th>
<td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td> <td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">VRF</th> <th scope="row">VRF</th>

View File

@ -65,7 +65,6 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'dcim/inc/nonracked_devices.html' %} {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -51,7 +51,6 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -40,7 +40,6 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -191,7 +191,6 @@
</div> </div>
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %} {% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -46,7 +46,6 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -87,11 +87,13 @@
<th scope="row">Physical Address</th> <th scope="row">Physical Address</th>
<td class="position-relative"> <td class="position-relative">
{% if object.physical_address %} {% if object.physical_address %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint"> {% if config.MAPS_URL %}
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm"> <div class="position-absolute top-50 end-0 translate-middle-y noprint">
<i class="mdi mdi-map-marker"></i> Map <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
</a> <i class="mdi mdi-map-marker"></i> Map
</div> </a>
</div>
{% endif %}
<span>{{ object.physical_address|linebreaksbr }}</span> <span>{{ object.physical_address|linebreaksbr }}</span>
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -106,11 +108,13 @@
<th scope="row">GPS Coordinates</th> <th scope="row">GPS Coordinates</th>
<td class="position-relative"> <td class="position-relative">
{% if object.latitude and object.longitude %} {% if object.latitude and object.longitude %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint"> {% if config.MAPS_URL %}
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm"> <div class="position-absolute top-50 end-0 translate-middle-y noprint">
<i class="mdi mdi-map-marker"></i> Map It <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
</a> <i class="mdi mdi-map-marker"></i> Map It
</div> </a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span> <span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -127,7 +131,6 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %} {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
{% include 'inc/panels/contacts.html' %}
<div class="card"> <div class="card">
<h5 class="card-header">Locations</h5> <h5 class="card-header">Locations</h5>
<div class='card-body'> <div class='card-body'>

View File

@ -42,7 +42,6 @@
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,63 +0,0 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Contacts</h5>
<div class="card-body">
{% with contacts=object.contacts.all %}
{% if contacts.exists %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Role</th>
<th>Priority</th>
<th>Phone</th>
<th>Email</th>
<th></th>
</tr>
{% for contact in contacts %}
<tr>
<td>{{ contact.contact|linkify }}</td>
<td>{{ contact.role|placeholder }}</td>
<td>{{ contact.get_priority_display|placeholder }}</td>
<td>
{% if contact.contact.phone %}
<a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>
{% if contact.contact.email %}
<a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td class="text-end noprint">
{% if perms.tenancy.change_contactassignment %}
<a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.tenancy.delete_contactassignment %}
<a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
{% endwith %}
</div>
{% if perms.tenancy.add_contactassignment %}
<div class="card-footer text-end noprint">
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
</div>
{% endif %}
</div>

View File

@ -12,8 +12,15 @@
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% for field, value in fields.items %} {% for field, value in fields.items %}
<tr> <tr>
<th scope="row"> <th scope="row">{{ field }}
<span title="{{ field.description|escape }}">{{ field }}</span> {% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th> </th>
<td> <td>
{% customfield_value field value %} {% customfield_value field value %}

View File

@ -3,13 +3,6 @@
{% load plugins %} {% load plugins %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-4"> <div class="col col-md-4">
@ -116,7 +109,6 @@
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %} {% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
<div class="card"> <div class="card">
<h5 class="card-header">Services</h5> <h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive" <div class="card-body htmx-container table-responsive"

View File

@ -0,0 +1,8 @@
{% extends 'generic/object.html' %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends 'ipam/ipaddress/base.html' %}
{% load helpers %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -37,7 +37,6 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}

View File

@ -0,0 +1,27 @@
{% extends base_template %}
{% load helpers %}
{% block extra_controls %}
{% if perms.tenancy.add_contactassignment %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
{% endif %}
{% endblock %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -30,7 +30,6 @@
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-5"> <div class="col col-md-5">

View File

@ -84,7 +84,6 @@
</div> </div>
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -37,7 +37,6 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -158,7 +158,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -59,7 +59,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">MAC Address</th> <th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td> <td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr> </tr>
<tr> <tr>
<th scope="row">802.1Q Mode</th> <th scope="row">802.1Q Mode</th>

View File

@ -7,17 +7,40 @@ from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, Vi
from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import register_model_view from utilities.views import register_model_view, ViewTab
from virtualization.models import VirtualMachine, Cluster from virtualization.models import VirtualMachine, Cluster
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
class ObjectContactsView(generic.ObjectChildrenView):
child_model = Contact
table = tables.ContactTable
filterset = filtersets.ContactFilterSet
template_name = 'tenancy/object_contacts.html'
tab = ViewTab(
label=_('Contacts'),
badge=lambda obj: obj.contacts.count(),
permission='tenancy.view_contact',
weight=5000
)
def get_children(self, request, parent):
return Contact.objects.annotate(
assignment_count=count_related(ContactAssignment, 'contact')
).restrict(request.user, 'view').filter(assignments__object_id=parent.pk)
def get_extra_context(self, request, instance):
return {
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
}
# #
# Tenant groups # Tenant groups
# #
class TenantGroupListView(generic.ObjectListView): class TenantGroupListView(generic.ObjectListView):
queryset = TenantGroup.objects.add_related_count( queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(), TenantGroup.objects.all(),
@ -165,6 +188,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView):
table = tables.TenantTable table = tables.TenantTable
@register_model_view(Tenant, 'contacts')
class TenantContactsView(ObjectContactsView):
queryset = Tenant.objects.all()
# #
# Contact groups # Contact groups
# #
@ -342,11 +370,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView):
filterset = filtersets.ContactFilterSet filterset = filtersets.ContactFilterSet
table = tables.ContactTable table = tables.ContactTable
# #
# Contact assignments # Contact assignments
# #
class ContactAssignmentListView(generic.ObjectListView): class ContactAssignmentListView(generic.ObjectListView):
queryset = ContactAssignment.objects.all() queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet filterset = filtersets.ContactAssignmentFilterSet

View File

@ -32,11 +32,11 @@ class BootstrapMixin:
elif isinstance(field.widget, forms.CheckboxInput): elif isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs['class'] = f'{css} form-check-input' field.widget.attrs['class'] = f'{css} form-check-input'
elif isinstance(field.widget, forms.SelectMultiple): elif isinstance(field.widget, forms.SelectMultiple) and 'size' in field.widget.attrs:
if 'size' not in field.widget.attrs: # Use native Bootstrap class for multi-line <select> widgets
field.widget.attrs['class'] = f'{css} netbox-static-select' field.widget.attrs['class'] = f'{css} form-select form-select-sm'
elif isinstance(field.widget, forms.Select): elif isinstance(field.widget, (forms.Select, forms.SelectMultiple)):
field.widget.attrs['class'] = f'{css} netbox-static-select' field.widget.attrs['class'] = f'{css} netbox-static-select'
else: else:

View File

@ -1,11 +1,12 @@
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rq import Worker from rq import Retry, Worker
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
__all__ = ( __all__ = (
'get_queue_for_model', 'get_queue_for_model',
'get_rq_retry',
'get_workers_for_queue', 'get_workers_for_queue',
) )
@ -22,3 +23,14 @@ def get_workers_for_queue(queue_name):
Returns True if a worker process is currently servicing the specified queue. Returns True if a worker process is currently servicing the specified queue.
""" """
return Worker.count(get_connection(queue_name)) return Worker.count(get_connection(queue_name))
def get_rq_retry():
"""
If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be
used when queuing a job. Otherwise, return None.
"""
retry_max = get_config().RQ_RETRY_MAX
retry_interval = get_config().RQ_RETRY_INTERVAL
if retry_max:
return Retry(max=retry_max, interval=retry_interval)

View File

@ -65,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Cluster model = Cluster
fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
class VirtualMachineImportForm(NetBoxModelImportForm): class VirtualMachineImportForm(NetBoxModelImportForm):

View File

@ -9,9 +9,10 @@ from dcim.filtersets import DeviceFilterSet
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 from extras.views import ObjectConfigContextView
from ipam.models import IPAddress, Service from ipam.models import IPAddress
from ipam.tables import InterfaceVLANTable from ipam.tables import InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -140,6 +141,11 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.ClusterGroupTable table = tables.ClusterGroupTable
@register_model_view(ClusterGroup, 'contacts')
class ClusterGroupContactsView(ObjectContactsView):
queryset = ClusterGroup.objects.all()
# #
# Clusters # Clusters
# #
@ -312,6 +318,11 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
}) })
@register_model_view(Cluster, 'contacts')
class ClusterContactsView(ObjectContactsView):
queryset = Cluster.objects.all()
# #
# Virtual machines # Virtual machines
# #
@ -390,6 +401,11 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
@register_model_view(VirtualMachine, 'contacts')
class VirtualMachineContactsView(ObjectContactsView):
queryset = VirtualMachine.objects.all()
# #
# VM interfaces # VM interfaces
# #

View File

@ -1,8 +1,8 @@
bleach==6.0.0 bleach==6.0.0
boto3==1.26.127 boto3==1.26.138
Django==4.1.9 Django==4.1.9
django-cors-headers==3.14.0 django-cors-headers==4.0.0
django-debug-toolbar==4.0.0 django-debug-toolbar==4.1.0
django-filter==23.2 django-filter==23.2
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14 django-mptt==0.14
@ -10,7 +10,7 @@ django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1
django-redis==5.2.0 django-redis==5.2.0
django-rich==1.5.0 django-rich==1.5.0
django-rq==2.8.0 django-rq==2.8.1
django-tables2==2.5.3 django-tables2==2.5.3
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==5.0 django-timezone-field==5.0
@ -19,17 +19,17 @@ drf-spectacular==0.26.2
drf-spectacular-sidecar==2023.5.1 drf-spectacular-sidecar==2023.5.1
dulwich==0.21.5 dulwich==0.21.5
feedparser==6.0.10 feedparser==6.0.10
graphene-django==3.0.0 graphene-django==3.0.2
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.1.9 mkdocs-material==9.1.14
mkdocstrings[python-legacy]==0.21.2 mkdocstrings[python-legacy]==0.21.2
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.5.0 Pillow==9.5.0
psycopg2-binary==2.9.6 psycopg2-binary==2.9.6
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.22.1 sentry-sdk==1.23.1
social-auth-app-django==5.2.0 social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3 svgwrite==1.4.3