']
+
+ If `models` is left as None, the extension will render for _all_ models.
+
+ The `render()` method provides the following context data:
* object - The object being viewed (object views only)
* model - The type of object being viewed (list views only)
@@ -21,7 +27,6 @@ class PluginTemplateExtension:
* config - Plugin-specific configuration parameters
"""
models = None
- model = None # Deprecated; use `models` instead
def __init__(self, context):
self.context = context
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index e5686561a..47991cdd2 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -9,6 +9,7 @@ import warnings
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
+from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from netbox.config import PARAMS as CONFIG_PARAMS
@@ -117,7 +118,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
-HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
+HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
@@ -132,6 +133,7 @@ MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media'
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
+PROXY_ROUTERS = getattr(configuration, 'PROXY_ROUTERS', ['utilities.proxy.DefaultProxyRouter'])
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
REDIS = getattr(configuration, 'REDIS') # Required
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
@@ -203,6 +205,14 @@ if RELEASE_CHECK_URL:
"RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox"
)
+# Validate configured proxy routers
+for path in PROXY_ROUTERS:
+ if type(path) is str:
+ try:
+ import_string(path)
+ except ImportError:
+ raise ImproperlyConfigured(f"Invalid path in PROXY_ROUTERS: {path}")
+
#
# Database
@@ -577,6 +587,7 @@ if SENTRY_ENABLED:
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=SENTRY_SEND_DEFAULT_PII,
+ # TODO: Support proxy routing
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py
deleted file mode 100644
index e6b946403..000000000
--- a/netbox/netbox/staging.py
+++ /dev/null
@@ -1,148 +0,0 @@
-import logging
-
-from django.contrib.contenttypes.models import ContentType
-from django.db import transaction
-from django.db.models.signals import m2m_changed, pre_delete, post_save
-
-from extras.choices import ChangeActionChoices
-from extras.models import StagedChange
-from utilities.serialization import serialize_object
-
-logger = logging.getLogger('netbox.staging')
-
-
-class checkout:
- """
- Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band
- (as Change instances) for application at a later time, without modifying the production
- database.
-
- branch = Branch.objects.create(name='my-branch')
- with checkout(branch):
- # All changes made herein will be rolled back and stored for later
-
- Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks,
- and restores its original value upon exit.
- """
- def __init__(self, branch):
- self.branch = branch
- self.queue = {}
-
- def __enter__(self):
-
- # Disable autocommit to effect a new transaction
- logger.debug(f"Entering transaction for {self.branch}")
- self._autocommit = transaction.get_autocommit()
- transaction.set_autocommit(False)
-
- # Apply any existing Changes assigned to this Branch
- staged_changes = self.branch.staged_changes.all()
- if change_count := staged_changes.count():
- logger.debug(f"Applying {change_count} pre-staged changes...")
- for change in staged_changes:
- change.apply()
- else:
- logger.debug("No pre-staged changes found")
-
- # Connect signal handlers
- logger.debug("Connecting signal handlers")
- post_save.connect(self.post_save_handler)
- m2m_changed.connect(self.post_save_handler)
- pre_delete.connect(self.pre_delete_handler)
-
- def __exit__(self, exc_type, exc_val, exc_tb):
-
- # Disconnect signal handlers
- logger.debug("Disconnecting signal handlers")
- post_save.disconnect(self.post_save_handler)
- m2m_changed.disconnect(self.post_save_handler)
- pre_delete.disconnect(self.pre_delete_handler)
-
- # Roll back the transaction to return the database to its original state
- logger.debug("Rolling back database transaction")
- transaction.rollback()
- logger.debug(f"Restoring autocommit state ({self._autocommit})")
- transaction.set_autocommit(self._autocommit)
-
- # Process queued changes
- self.process_queue()
-
- #
- # Queuing
- #
-
- @staticmethod
- def get_key_for_instance(instance):
- return ContentType.objects.get_for_model(instance), instance.pk
-
- def process_queue(self):
- """
- Create Change instances for all actions stored in the queue.
- """
- if not self.queue:
- logger.debug("No queued changes; aborting")
- return
- logger.debug(f"Processing {len(self.queue)} queued changes")
-
- # Iterate through the in-memory queue, creating Change instances
- changes = []
- for key, change in self.queue.items():
- logger.debug(f' {key}: {change}')
- object_type, pk = key
- action, data = change
-
- changes.append(StagedChange(
- branch=self.branch,
- action=action,
- object_type=object_type,
- object_id=pk,
- data=data
- ))
-
- # Save all Change instances to the database
- StagedChange.objects.bulk_create(changes)
-
- #
- # Signal handlers
- #
-
- def post_save_handler(self, sender, instance, **kwargs):
- """
- Hooks to the post_save signal when a branch is active to queue create and update actions.
- """
- key = self.get_key_for_instance(instance)
- object_type = instance._meta.verbose_name
-
- # Creating a new object
- if kwargs.get('created'):
- logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data)
- return
-
- # Ignore pre_* many-to-many actions
- if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'):
- return
-
- # Object has already been created/updated in the queue; update its queued representation
- if key in self.queue:
- logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (self.queue[key][0], data)
- return
-
- # Modifying an existing object for the first time
- logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data)
-
- def pre_delete_handler(self, sender, instance, **kwargs):
- """
- Hooks to the pre_delete signal when a branch is active to queue delete actions.
- """
- key = self.get_key_for_instance(instance)
- object_type = instance._meta.verbose_name
-
- # Delete an existing object
- logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})")
- self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None)
diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py
deleted file mode 100644
index 0a73b2987..000000000
--- a/netbox/netbox/tests/test_staging.py
+++ /dev/null
@@ -1,216 +0,0 @@
-from django.db.models.signals import post_save
-from django.test import TransactionTestCase
-
-from circuits.models import Provider, Circuit, CircuitType
-from extras.choices import ChangeActionChoices
-from extras.models import Branch, StagedChange, Tag
-from ipam.models import ASN, RIR
-from netbox.search.backends import search_backend
-from netbox.staging import checkout
-from utilities.testing import create_tags
-
-
-class StagingTestCase(TransactionTestCase):
-
- def setUp(self):
- # Disconnect search backend to avoid issues with cached ObjectTypes being deleted
- # from the database upon transaction rollback
- post_save.disconnect(search_backend.caching_handler)
-
- create_tags('Alpha', 'Bravo', 'Charlie')
-
- rir = RIR.objects.create(name='RIR 1', slug='rir-1')
- asns = (
- ASN(asn=65001, rir=rir),
- ASN(asn=65002, rir=rir),
- ASN(asn=65003, rir=rir),
- )
- ASN.objects.bulk_create(asns)
-
- providers = (
- Provider(name='Provider A', slug='provider-a'),
- Provider(name='Provider B', slug='provider-b'),
- Provider(name='Provider C', slug='provider-c'),
- )
- Provider.objects.bulk_create(providers)
-
- circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
-
- Circuit.objects.bulk_create((
- Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type),
- Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type),
- Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type),
- ))
-
- def test_object_creation(self):
- branch = Branch.objects.create(name='Branch 1')
- tags = Tag.objects.all()
- asns = ASN.objects.all()
-
- with checkout(branch):
- provider = Provider.objects.create(name='Provider D', slug='provider-d')
- provider.asns.set(asns)
- circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first())
- circuit.tags.set(tags)
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 4)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 10)
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(StagedChange.objects.count(), 5)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 4)
- self.assertEqual(Circuit.objects.count(), 10)
- provider = Provider.objects.get(name='Provider D')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- circuit = Circuit.objects.get(cid='Circuit D1')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 4)
- self.assertEqual(Circuit.objects.count(), 10)
- provider = Provider.objects.get(name='Provider D')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- circuit = Circuit.objects.get(cid='Circuit D1')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_object_modification(self):
- branch = Branch.objects.create(name='Branch 1')
- tags = Tag.objects.all()
- asns = ASN.objects.all()
-
- with checkout(branch):
- provider = Provider.objects.get(name='Provider A')
- provider.name = 'Provider X'
- provider.save()
- provider.asns.set(asns)
- circuit = Circuit.objects.get(cid='Circuit A1')
- circuit.cid = 'Circuit X'
- circuit.save()
- circuit.tags.set(tags)
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), [])
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit A1')
- self.assertListEqual(list(circuit.tags.all()), [])
- self.assertEqual(StagedChange.objects.count(), 5)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_object_deletion(self):
- branch = Branch.objects.create(name='Branch 1')
-
- with checkout(branch):
- provider = Provider.objects.get(name='Provider A')
- provider.circuits.all().delete()
- provider.delete()
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(StagedChange.objects.count(), 4)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_exit_enter_context(self):
- branch = Branch.objects.create(name='Branch 1')
-
- with checkout(branch):
-
- # Create a new object
- provider = Provider.objects.create(name='Provider D', slug='provider-d')
- provider.save()
-
- # Check that a create Change was recorded
- self.assertEqual(StagedChange.objects.count(), 1)
- change = StagedChange.objects.first()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE)
- self.assertEqual(change.data['name'], provider.name)
-
- with checkout(branch):
-
- # Update the staged object
- provider = Provider.objects.get(name='Provider D')
- provider.comments = 'New comments'
- provider.save()
-
- # Check that a second Change object has been created for the object
- self.assertEqual(StagedChange.objects.count(), 2)
- change = StagedChange.objects.last()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE)
- self.assertEqual(change.data['name'], provider.name)
- self.assertEqual(change.data['comments'], provider.comments)
-
- with checkout(branch):
-
- # Delete the staged object
- provider = Provider.objects.get(name='Provider D')
- provider.delete()
-
- # Check that a third Change has recorded the object's deletion
- self.assertEqual(StagedChange.objects.count(), 3)
- change = StagedChange.objects.last()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE)
- self.assertIsNone(change.data)
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index 2cb549a0d..5dbcf97cf 100644
Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 361af0112..b26038ef4 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -23,7 +23,7 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
- "@tabler/core": "1.0.0-beta21",
+ "@tabler/core": "1.0.0",
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
@@ -53,5 +53,6 @@
},
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
- }
+ },
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/netbox/project-static/styles/_variables.scss b/netbox/project-static/styles/_variables.scss
index 33b144532..c493149dd 100644
--- a/netbox/project-static/styles/_variables.scss
+++ b/netbox/project-static/styles/_variables.scss
@@ -2,7 +2,6 @@
// Set base fonts
$font-family-sans-serif: 'Inter', system-ui, sans-serif;
-// See https://github.com/tabler/tabler/issues/1812
$font-family-monospace: 'Roboto Mono';
// Set the navigation sidebar width
@@ -16,9 +15,6 @@ $btn-padding-y: 0.25rem;
$table-cell-padding-x: 0.5rem;
$table-cell-padding-y: 0.5rem;
-// Fix Tabler bug #1694 in 1.0.0-beta20
-$hover-bg: rgba(var(--tblr-secondary-rgb), 0.08);
-
// Ensure active nav-pill has a background color in dark mode
$nav-pills-link-active-bg: rgba(var(--tblr-secondary-rgb), 0.15);
diff --git a/netbox/project-static/styles/transitional/_navigation.scss b/netbox/project-static/styles/transitional/_navigation.scss
index 67aa19935..d31f1cc82 100644
--- a/netbox/project-static/styles/transitional/_navigation.scss
+++ b/netbox/project-static/styles/transitional/_navigation.scss
@@ -8,8 +8,8 @@
// Adjust hover color & style for menu items
.navbar-collapse {
- .nav-link-icon {
- color: var(--tblr-nav-link-color) !important;
+ .nav-link-icon, .nav-link-title {
+ color: $rich-black;
}
.text-secondary {
color: $dark-teal !important;
@@ -26,8 +26,8 @@
visibility: hidden;
}
- // Style menu item hover state
- &:hover {
+ // Style menu item hover/active state
+ &:hover, &.active {
background-color: var(--tblr-navbar-active-bg);
a {
text-decoration: none;
@@ -37,17 +37,6 @@
}
}
- // Style active menu item
- &.active {
- background-color: var(--tblr-navbar-active-bg);
- a {
- color: $rich-black;
- }
- .btn-group {
- visibility: visible;
- }
- }
-
}
}
}
@@ -109,22 +98,17 @@ body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
border-color: $bright-teal !important;
}
+ .nav-link-title, .nav-link-icon {
+ color: white !important;
+ }
+
// Adjust hover color & style for menu items
.dropdown-item {
a {
color: white !important;
}
- &.active {
+ &.active, &:hover {
background-color: $navbar-dark-active-bg !important;
- a {
- color: white !important;
- }
- }
- &:hover {
- background-color: $navbar-dark-active-bg !important;
- }
- .nav-link-title {
- color: white !important;
}
}
.text-secondary {
diff --git a/netbox/project-static/styles/transitional/_tables.scss b/netbox/project-static/styles/transitional/_tables.scss
index 4094631ca..77ce925c1 100644
--- a/netbox/project-static/styles/transitional/_tables.scss
+++ b/netbox/project-static/styles/transitional/_tables.scss
@@ -1,3 +1,8 @@
+// Reduce column heading font size
+.table thead th {
+ font-size: 0.625rem;
+}
+
// Object list tables
table.object-list {
diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index 92e7e7bd1..02ea00c6e 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -759,19 +759,19 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
-"@tabler/core@1.0.0-beta21":
- version "1.0.0-beta21"
- resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.0.0-beta21.tgz#cd10d7648b3b7b31927a430fd776d3304e796403"
- integrity sha512-9ZKu38BScc0eHruhX/SlVDSiXenBFSgBp2WDq6orkuC8J/1yutKDt7CdXuJpBwkiADEk5yqYV31Ku+CnhwOc3Q==
+"@tabler/core@1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.0.0.tgz#08736378108663b5893a31ad462be7d12e64be67"
+ integrity sha512-uFmv6f8TAaW2JaGwzjT1LfK+TjmBQSTCoznCMdV5uur4cv4TtJlV8Hh1Beu55YX0svMtOQ0Xts7tYv/+qBEcfA==
dependencies:
"@popperjs/core" "^2.11.8"
- "@tabler/icons" "^3.14.0"
+ "@tabler/icons" "^3.29.0"
bootstrap "5.3.3"
-"@tabler/icons@^3.14.0":
- version "3.16.0"
- resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.16.0.tgz#d618670b80163925a31a6c2290e8775f6058d81a"
- integrity sha512-GU7MSx4uQEr55BmyON6hD/QYTl6k1v0YlRhM91gBWDoKAbyCt6QIYw7rpJ/ecdh5zrHaTOJKPenZ4+luoutwFA==
+"@tabler/icons@^3.29.0":
+ version "3.30.0"
+ resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.30.0.tgz#4f80f52cc6355b440a4ee0dadd4c3e3775e50663"
+ integrity sha512-c8OKLM48l00u9TFbh2qhSODMONIzML8ajtCyq95rW8vzkWcBrKRPM61tdkThz2j4kd5u17srPGIjqdeRUZdfdw==
"@tanstack/react-virtual@^3.0.0-beta.60":
version "3.5.0"
diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html
index a5afedec6..0d56c4087 100644
--- a/netbox/templates/core/datasource.html
+++ b/netbox/templates/core/datasource.html
@@ -46,6 +46,10 @@
{% trans "Status" %} |
{% badge object.get_status_display bg_color=object.get_status_color %} |
+
+ {% trans "Sync interval" %} |
+ {{ object.get_sync_interval_display|placeholder }} |
+
{% trans "Last synced" %} |
{{ object.last_synced|placeholder }} |
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html
index 146f6d580..8e44df88e 100644
--- a/netbox/templates/dcim/poweroutlet.html
+++ b/netbox/templates/dcim/poweroutlet.html
@@ -36,6 +36,10 @@
{% trans "Type" %} |
{{ object.get_type_display }} |
+
+ {% trans "Status" %} |
+ {% badge object.get_status_display bg_color=object.get_status_color %} |
+
{% trans "Description" %} |
{{ object.description|placeholder }} |
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html
index f994852be..abc998de3 100644
--- a/netbox/templates/ipam/vlangroup.html
+++ b/netbox/templates/ipam/vlangroup.html
@@ -46,6 +46,15 @@
Utilization |
{% utilization_graph object.utilization %} |
+
+ {% trans "Tenant" %} |
+
+ {% if object.tenant.group %}
+ {{ object.tenant.group|linkify }} /
+ {% endif %}
+ {{ object.tenant|linkify|placeholder }}
+ |
+
{% include 'inc/panels/tags.html' %}
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 0988d2e65..3b5029bd7 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -17,25 +17,13 @@ class ObjectContactsView(generic.ObjectChildrenView):
template_name = 'tenancy/object_contacts.html'
tab = ViewTab(
label=_('Contacts'),
- badge=lambda obj: obj.contacts.count(),
+ badge=lambda obj: obj.get_contacts().count(),
permission='tenancy.view_contactassignment',
weight=5000
)
def get_children(self, request, parent):
- return ContactAssignment.objects.restrict(request.user, 'view').filter(
- object_type=ContentType.objects.get_for_model(parent),
- object_id=parent.pk
- ).order_by('priority', 'contact', 'role')
-
- def get_table(self, *args, **kwargs):
- table = super().get_table(*args, **kwargs)
-
- # Hide object columns
- table.columns.hide('object_type')
- table.columns.hide('object')
-
- return table
+ return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role')
#
diff --git a/netbox/utilities/proxy.py b/netbox/utilities/proxy.py
new file mode 100644
index 000000000..8c9e3d196
--- /dev/null
+++ b/netbox/utilities/proxy.py
@@ -0,0 +1,55 @@
+from django.conf import settings
+from django.utils.module_loading import import_string
+from urllib.parse import urlparse
+
+__all__ = (
+ 'DefaultProxyRouter',
+ 'resolve_proxies',
+)
+
+
+class DefaultProxyRouter:
+ """
+ Base class for a proxy router.
+ """
+ @staticmethod
+ def _get_protocol_from_url(url):
+ """
+ Determine the applicable protocol (e.g. HTTP or HTTPS) from the given URL.
+ """
+ return urlparse(url).scheme
+
+ def route(self, url=None, protocol=None, context=None):
+ """
+ Returns the appropriate proxy given a URL or protocol. Arbitrary context data may also be passed where
+ available.
+
+ Args:
+ url: The specific request URL for which the proxy will be used (if known)
+ protocol: The protocol in use (e.g. http or https) (if known)
+ context: Additional context to aid in proxy selection. May include e.g. the requesting client.
+ """
+ if url and protocol is None:
+ protocol = self._get_protocol_from_url(url)
+ if protocol and protocol in settings.HTTP_PROXIES:
+ return {
+ protocol: settings.HTTP_PROXIES[protocol]
+ }
+ return settings.HTTP_PROXIES
+
+
+def resolve_proxies(url=None, protocol=None, context=None):
+ """
+ Return a dictionary of candidate proxies (compatible with the requests module), or None.
+
+ Args:
+ url: The specific request URL for which the proxy will be used (optional)
+ protocol: The protocol in use (e.g. http or https) (optional)
+ context: Arbitrary additional context to aid in proxy selection (optional)
+ """
+ context = context or {}
+
+ for item in settings.PROXY_ROUTERS:
+ router = import_string(item) if type(item) is str else item
+ if proxies := router().route(url=url, protocol=protocol, context=context):
+ return proxies
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index b3334ca87..5a9830918 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -206,22 +206,30 @@ class ViewTab:
Args:
label: Human-friendly text
+ visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one
+ argument: the object instance. If a callable is not specified, the tab's visibility will be determined by
+ its badge (if any) and the value of `hide_if_empty`.
badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
accept a single argument representing the object being viewed.
weight: Numeric weight to influence ordering among other tabs (default: 1000)
permission: The permission required to display the tab (optional).
- hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a
- badge are always displayed.)
+ hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is
+ evaluated only if the tab is permitted to be displayed according to the `visible` parameter.)
"""
- def __init__(self, label, badge=None, weight=1000, permission=None, hide_if_empty=False):
+ def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False):
self.label = label
+ self.visible = visible
self.badge = badge
self.weight = weight
self.permission = permission
self.hide_if_empty = hide_if_empty
def render(self, instance):
- """Return the attributes needed to render a tab in HTML."""
+ """
+ Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None.
+ """
+ if self.visible is not None and not self.visible(instance):
+ return None
badge_value = self._get_badge_value(instance)
if self.badge and self.hide_if_empty and not badge_value:
return None
diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py
index 9847e1b97..7aea90232 100644
--- a/netbox/vpn/choices.py
+++ b/netbox/vpn/choices.py
@@ -228,6 +228,7 @@ class L2VPNTypeChoices(ChoiceSet):
TYPE_MPLS_EVPN = 'mpls-evpn'
TYPE_PBB_EVPN = 'pbb-evpn'
TYPE_EVPN_VPWS = 'evpn-vpws'
+ TYPE_SPB = 'spb'
CHOICES = (
('VPLS', (
@@ -255,6 +256,9 @@ class L2VPNTypeChoices(ChoiceSet):
(TYPE_EPTREE, _('Ethernet Private Tree')),
(TYPE_EVPTREE, _('Ethernet Virtual Private Tree')),
)),
+ ('Other', (
+ (TYPE_SPB, _('SPB')),
+ )),
)
P2P = (