Merge branch 'develop' into 10653-log-failed-logins

This commit is contained in:
Arthur 2022-11-17 12:50:20 -08:00
commit 2b2a50ac3a
25 changed files with 224 additions and 138 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.3.7 placeholder: v3.3.8
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.3.7 placeholder: v3.3.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root. Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight ```no-highlight
sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
sudo tar -xzf vX.Y.Z.tar.gz -C /opt sudo tar -xzf vX.Y.Z.tar.gz -C /opt
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
``` ```

View File

@ -1,6 +1,32 @@
# NetBox v3.3 # NetBox v3.3
## v3.3.8 (FUTURE) ## v3.3.9 (FUTURE)
---
## v3.3.8 (2022-11-16)
### Enhancements
* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
### Bug Fixes
* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
--- ---

View File

@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet):
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'
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr'
TYPE_40GE_KR4 = '40gbase-kr4'
TYPE_50GE_KR = '50gbase-kr'
TYPE_100GE_KP4 = '100gbase-kp4'
TYPE_100GE_KR2 = '100gbase-kr2'
TYPE_100GE_KR4 = '100gbase-kr4'
# Wireless # Wireless
TYPE_80211A = 'ieee802.11a' TYPE_80211A = 'ieee802.11a'
TYPE_80211G = 'ieee802.11g' TYPE_80211G = 'ieee802.11g'
@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
) )
), ),
(
'Ethernet (backplane)',
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
(TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
(TYPE_50GE_KR, '50GBASE-KR (50GE)'),
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
)
),
( (
'Wireless', 'Wireless',
( (

View File

@ -877,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm):
'site_id': '$site' 'site_id': '$site'
} }
) )
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site'
},
initial_params={
'racks': '$rack'
}
)
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
query_params={ query_params={
'location_id': '$location',
'site_id': '$site' 'site_id': '$site'
} }
) )
@ -888,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
('Power Panel', ('region', 'site', 'power_panel')), ('Power Panel', ('region', 'site', 'power_panel')),
('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
) )
class Meta: class Meta:
model = PowerFeed model = PowerFeed
fields = [ fields = [
'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
] ]
widgets = { widgets = {

View File

@ -166,7 +166,7 @@ class CableTraceSVG:
""" """
if hasattr(instance, 'parent_object'): if hasattr(instance, 'parent_object'):
# Termination # Termination
return 'f0f0f0' return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
if hasattr(instance, 'device_role'): if hasattr(instance, 'device_role'):
# Device # Device
return instance.device_role.color return instance.device_role.color

View File

@ -1,10 +1,6 @@
from contextlib import contextmanager from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save from netbox.context import current_request, webhooks_queue
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from .webhooks import flush_webhooks from .webhooks import flush_webhooks
@ -16,27 +12,14 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set :param request: WSGIRequest object with a unique `id` set
""" """
set_request(request) current_request.set(request)
thread_locals.webhook_queue = [] webhooks_queue.set([])
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
yield yield
# Disconnect change logging signals. This is necessary to avoid recording any errant
# changes during test cleanup.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ # Flush queued webhooks to RQ
flush_webhooks(thread_locals.webhook_queue) flush_webhooks(webhooks_queue.get())
del thread_locals.webhook_queue
# Clear the request from thread-local storage # Clear context vars
set_request(None) current_request.set(None)
webhooks_queue.set([])

View File

@ -7,14 +7,14 @@ from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.validators import CustomValidator from extras.validators import CustomValidator
from netbox import thread_locals
from netbox.config import get_config from netbox.config import get_config
from netbox.request_context import get_request from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
# #
# Change logging/webhooks # Change logging/webhooks
# #
@ -23,22 +23,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal() clear_webhooks = Signal()
def is_same_object(instance, webhook_data, request_id):
"""
Compare the given instance to the most recent queued webhook object, returning True
if they match. This check is used to avoid creating duplicate webhook entries.
"""
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request_id == webhook_data['request_id']
)
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs): def handle_changed_object(sender, instance, **kwargs):
""" """
Fires when an object is created or updated. Fires when an object is created or updated.
""" """
m2m_changed = False
if not hasattr(instance, 'to_objectchange'): if not hasattr(instance, 'to_objectchange'):
return return
request = get_request() # Get the current request, or bail if not set
m2m_changed = False request = current_request.get()
if request is None:
def is_same_object(instance, webhook_data): return
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request.id == webhook_data['request_id']
)
# Determine the type of change being made # Determine the type of change being made
if kwargs.get('created'): if kwargs.get('created'):
@ -69,13 +79,14 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.save() objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save) # If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue queue = webhooks_queue.get()
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]): if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance) queue[-1]['data'] = serialize_for_webhook(instance)
webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange'] queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else: else:
enqueue_object(webhook_queue, instance, request.user, request.id, action) enqueue_object(queue, instance, request.user, request.id, action)
webhooks_queue.set(queue)
# Increment metric counters # Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE: if action == ObjectChangeActionChoices.ACTION_CREATE:
@ -84,6 +95,7 @@ def handle_changed_object(sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc() model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs): def handle_deleted_object(sender, instance, **kwargs):
""" """
Fires when an object is deleted. Fires when an object is deleted.
@ -91,7 +103,10 @@ def handle_deleted_object(sender, instance, **kwargs):
if not hasattr(instance, 'to_objectchange'): if not hasattr(instance, 'to_objectchange'):
return return
request = get_request() # Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Record an ObjectChange if applicable # Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'): if hasattr(instance, 'to_objectchange'):
@ -101,22 +116,22 @@ def handle_deleted_object(sender, instance, **kwargs):
objectchange.save() objectchange.save()
# Enqueue webhooks # Enqueue webhooks
webhook_queue = thread_locals.webhook_queue queue = webhooks_queue.get()
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
webhooks_queue.set(queue)
# Increment metric counters # Increment metric counters
model_deletes.labels(instance._meta.model_name).inc() model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_webhooks)
def clear_webhook_queue(sender, **kwargs): def clear_webhook_queue(sender, **kwargs):
""" """
Delete any queued webhooks (e.g. because of an aborted bulk transaction) Delete any queued webhooks (e.g. because of an aborted bulk transaction)
""" """
logger = logging.getLogger('webhooks') logger = logging.getLogger('webhooks')
webhook_queue = thread_locals.webhook_queue logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
webhooks_queue.set([])
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()
# #

View File

@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm): class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service model = Service
tag = TagFilterField(model)
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):

View File

@ -549,6 +549,11 @@ class FHRPGroupForm(NetBoxModelForm):
fields = ( fields = (
'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
) )
widgets = {
'protocol': StaticSelect(),
'auth_type': StaticSelect(),
'ip_status': StaticSelect(),
}
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)

View File

@ -1,3 +0,0 @@
import threading
thread_locals = threading.local()

10
netbox/netbox/context.py Normal file
View File

@ -0,0 +1,10 @@
from contextvars import ContextVar
__all__ = (
'current_request',
'webhooks_queue',
)
current_request = ContextVar('current_request', default=None)
webhooks_queue = ContextVar('webhooks_queue')

View File

@ -1,9 +0,0 @@
from netbox import thread_locals
def set_request(request):
thread_locals.request = request
def get_request():
return getattr(thread_locals, 'request', None)

View File

@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup # Environment setup
# #
VERSION = '3.3.8-dev' VERSION = '3.3.9-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -81,11 +81,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []
BASE_PATH = getattr(configuration, 'BASE_PATH', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH: if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
CSRF_COOKIE_PATH = BASE_PATH or '/'
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@ -130,8 +130,6 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SESSION_COOKIE_PATH = BASE_PATH or '/'
LANGUAGE_COOKIE_PATH = BASE_PATH or '/'
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')

View File

@ -64,19 +64,19 @@
<h5 class="card-header">Environment</h5> <h5 class="card-header">Environment</h5>
<div class="card-body"> <div class="card-body">
<table class="table"> <table class="table">
<tr id="status-cpu" class="bg-light"> <tr id="status-cpu">
<th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th> <th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th>
</tr> </tr>
<tr id="status-memory" class="bg-light"> <tr id="status-memory">
<th colspan="2"><i class="mdi mdi-chip"></i> Memory</th> <th colspan="2"><i class="mdi mdi-chip"></i> Memory</th>
</tr> </tr>
<tr id="status-temperature" class="bg-light"> <tr id="status-temperature">
<th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th> <th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th>
</tr> </tr>
<tr id="status-fans" class="bg-light"> <tr id="status-fans">
<th colspan="2"><i class="mdi mdi-fan"></i> Fans</th> <th colspan="2"><i class="mdi mdi-fan"></i> Fans</th>
</tr> </tr>
<tr id="status-power" class="bg-light"> <tr id="status-power">
<th colspan="2"><i class="mdi mdi-power"></i> Power</th> <th colspan="2"><i class="mdi mdi-power"></i> Power</th>
</tr> </tr>
<tr class="napalm-table-placeholder d-none invisible"> <tr class="napalm-table-placeholder d-none invisible">

View File

@ -7,6 +7,10 @@
<td>Site</td> <td>Site</td>
<td>{{ terminations.0.device.site|linkify }}</td> <td>{{ terminations.0.device.site|linkify }}</td>
</tr> </tr>
<tr>
<td>Location</td>
<td>{{ terminations.0.device.location|linkify|placeholder }}</td>
</tr>
<tr> <tr>
<td>Rack</td> <td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td> <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>

View File

@ -4,10 +4,24 @@
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.dcim.add_devicetype or perms.dcim.add_moduletype %}
<div class="dropdown">
<button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.dcim.add_devicetype %} {% if perms.dcim.add_devicetype %}
<a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-sm btn-primary"> <li><a class="dropdown-item" href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device Type Add Device Type
</a> </a></li>
{% endif %}
{% if perms.dcim.add_moduletype %}
<li><a class="dropdown-item" href="{% url 'dcim:moduletype_add' %}?manufacturer={{ object.pk }}">
Add Module Type
</a></li>
{% endif %}
</ul>
</div>
{% endif %} {% endif %}
{% endblock extra_controls %} {% endblock extra_controls %}

View File

@ -77,10 +77,10 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a> <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
</li> </li>
<li> <li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a> <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
</li> </li>
</ul> </ul>
</span> </span>

View File

@ -67,11 +67,12 @@ Context:
{% applied_filters filter_form request.GET %} {% applied_filters filter_form request.GET %}
{% endif %} {% endif %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
{# "Select all" form #} {# "Select all" form #}
{% if table.paginator.num_pages > 1 %} {% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card noprint"> <div id="select-all-box" class="d-none card noprint">
<form method="post" class="form col-md-12"> <div class="form col-md-12">
{% csrf_token %}
<div class="card-body"> <div class="card-body">
<div class="float-end"> <div class="float-end">
{% if 'bulk_edit' in actions %} {% if 'bulk_edit' in actions %}
@ -88,14 +89,14 @@ Context:
</label> </label>
</div> </div>
</div> </div>
</form> </div>
</div> </div>
{% endif %} {% endif %}
{# Object table controls #} {# Object table controls #}
{% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
<form method="post" class="form form-horizontal"> <div class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" /> <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
@ -124,7 +125,7 @@ Context:
{% endblock %} {% endblock %}
</div> </div>
</div> </div>
</div>
</form> </form>
</div> </div>

View File

@ -25,7 +25,7 @@
<tr> <tr>
<th scope="row">Assignments</th> <th scope="row">Assignments</th>
<td> <td>
<a href="{% url 'tenancy:contact_list' %}?role={{ object.slug }}">{{ assignment_count }}</a> {{ assignment_count }}
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -93,6 +93,12 @@
<h2><a href="{% url 'ipam:vlan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2> <h2><a href="{% url 'ipam:vlan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
<p>VLANs</p> <p>VLANs</p>
</div> </div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:l2vpn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.l2vpn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.l2vpn_count }}</a></h2>
<p>L2VPNs</p>
</div>
<div class="col col-md-4 text-center"> <div class="col col-md-4 text-center">
<h2><a href="{% url 'circuits:circuit_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2> <h2><a href="{% url 'circuits:circuit_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
<p>Circuits</p> <p>Circuits</p>

View File

@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from circuits.models import Circuit from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from ipam.models import Aggregate, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF, ASN
from netbox.views import generic from netbox.views import generic
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster from virtualization.models import VirtualMachine, Cluster
@ -111,6 +111,7 @@ class TenantView(generic.ObjectView):
'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),

View File

@ -410,6 +410,7 @@ def copy_safe_request(request):
} }
return NetBoxFakeRequest({ return NetBoxFakeRequest({
'META': meta, 'META': meta,
'COOKIES': request.COOKIES,
'POST': request.POST, 'POST': request.POST,
'GET': request.GET, 'GET': request.GET,
'FILES': request.FILES, 'FILES': request.FILES,

View File

@ -9,7 +9,7 @@ django-pglocks==1.0.4
django-prometheus==2.2.0 django-prometheus==2.2.0
django-redis==5.2.0 django-redis==5.2.0
django-rich==1.4.0 django-rich==1.4.0
django-rq==2.5.1 django-rq==2.6.0
django-tables2==2.4.1 django-tables2==2.4.1
django-taggit==3.0.0 django-taggit==3.0.0
django-timezone-field==5.0 django-timezone-field==5.0
@ -19,13 +19,13 @@ graphene-django==2.15.0
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==8.5.7 mkdocs-material==8.5.10
mkdocstrings[python-legacy]==0.19.0 mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.3.0 Pillow==9.3.0
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.10.1 sentry-sdk==1.11.0
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0 social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3 svgwrite==1.4.3
@ -34,6 +34,3 @@ tzdata==2022.6
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0
# Temporary fix for #10712
swagger-spec-validator==2.7.6