mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-04 08:27:17 +02:00
Compare commits
19 Commits
v4.5.6
...
21766-impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
209c60ea6e | ||
|
|
f058ee3d60 | ||
|
|
49ba0dd495 | ||
|
|
b4ee2cf447 | ||
|
|
34098bb20a | ||
|
|
a19daa5466 | ||
|
|
40eec679d9 | ||
|
|
57556e3fdb | ||
|
|
f2d8ae29c2 | ||
|
|
f6eb5dda0f | ||
|
|
c7bbfb24c5 | ||
|
|
e98e5e11a7 | ||
|
|
3ce2bf75b4 | ||
|
|
b1af9a7218 | ||
|
|
b73f7f7d00 | ||
|
|
9492b55f4b | ||
|
|
2563122352 | ||
|
|
0455e14c29 | ||
|
|
b8b12f3f90 |
@@ -384,6 +384,18 @@ A calendar date. Returns a `datetime.date` object.
|
||||
|
||||
A complete date & time. Returns a `datetime.datetime` object.
|
||||
|
||||
## Uploading Scripts via the API
|
||||
|
||||
Script modules can be uploaded to NetBox via the REST API by sending a `multipart/form-data` POST request to `/api/extras/scripts/upload/`. The caller must have the `extras.add_scriptmodule` and `core.add_managedfile` permissions.
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-F "file=@/path/to/myscript.py" \
|
||||
http://netbox/api/extras/scripts/upload/
|
||||
```
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Search
|
||||
|
||||
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
|
||||
Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models.
|
||||
|
||||
```python
|
||||
```python title="search.py"
|
||||
# search.py
|
||||
from netbox.search import SearchIndex
|
||||
from netbox.search import SearchIndex, register_search
|
||||
|
||||
from .models import MyModel
|
||||
|
||||
@register_search
|
||||
class MyModelIndex(SearchIndex):
|
||||
model = MyModel
|
||||
fields = (
|
||||
@@ -17,15 +19,11 @@ class MyModelIndex(SearchIndex):
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
|
||||
Decorate each `SearchIndex` subclass with `@register_search` to register it with NetBox. When using the default `search.py` module, no additional `indexes = [...]` list is required.
|
||||
|
||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
||||
|
||||
```python
|
||||
indexes = [MyModelIndex]
|
||||
```
|
||||
Fields listed in `display_attrs` are not cached for matching, but they are displayed alongside the object in global search results to provide additional context.
|
||||
|
||||
!!! tip
|
||||
The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
|
||||
The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns.
|
||||
|
||||
::: netbox.search.SearchIndex
|
||||
|
||||
@@ -95,6 +95,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
|
||||
verbose_name=_('Provider network')
|
||||
)
|
||||
provider_account = tables.Column(
|
||||
accessor=tables.A('virtual_circuit__provider_account'),
|
||||
linkify=True,
|
||||
verbose_name=_('Account')
|
||||
)
|
||||
@@ -112,7 +113,7 @@ class VirtualCircuitTerminationTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualCircuitTermination
|
||||
fields = (
|
||||
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
|
||||
'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interface',
|
||||
'description', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
from django.test import RequestFactory, TestCase, tag
|
||||
|
||||
from circuits.models import CircuitGroupAssignment, CircuitTermination
|
||||
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
|
||||
from circuits.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class CircuitTerminationTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
terminations = CircuitTermination.objects.all()
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
|
||||
orderable_columns = [
|
||||
column.name
|
||||
for column in CircuitTerminationTable(terminations).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
table = CircuitTerminationTable(terminations)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
class CircuitTypeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CircuitTypeTable
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class CircuitGroupAssignmentTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
assignment = CircuitGroupAssignment.objects.all()
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
class CircuitTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CircuitTable
|
||||
|
||||
orderable_columns = [
|
||||
column.name
|
||||
for column in CircuitGroupAssignmentTable(assignment).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
table = CircuitGroupAssignmentTable(assignment)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
class CircuitTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CircuitTerminationTable
|
||||
|
||||
|
||||
class CircuitGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CircuitGroupTable
|
||||
|
||||
|
||||
class CircuitGroupAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CircuitGroupAssignmentTable
|
||||
|
||||
|
||||
class ProviderTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ProviderTable
|
||||
|
||||
|
||||
class ProviderAccountTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ProviderAccountTable
|
||||
|
||||
|
||||
class ProviderNetworkTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ProviderNetworkTable
|
||||
|
||||
|
||||
class VirtualCircuitTypeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VirtualCircuitTypeTable
|
||||
|
||||
|
||||
class VirtualCircuitTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VirtualCircuitTable
|
||||
|
||||
|
||||
class VirtualCircuitTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VirtualCircuitTerminationTable
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_rq.queues import get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST
|
||||
from django_rq.settings import get_queues_list
|
||||
from django_rq.utils import get_statistics
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
@@ -195,7 +195,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
|
||||
return 'Background Workers'
|
||||
|
||||
def get_data(self):
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
return Worker.all(get_redis_connection(config['connection_config']))
|
||||
|
||||
@extend_schema(
|
||||
@@ -205,7 +205,7 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
|
||||
)
|
||||
def retrieve(self, request, name):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
workers = Worker.all(get_redis_connection(config['connection_config']))
|
||||
worker = next((item for item in workers if item.name == name), None)
|
||||
if not worker:
|
||||
@@ -229,7 +229,7 @@ class BackgroundTaskViewSet(BaseRQViewSet):
|
||||
return get_rq_jobs()
|
||||
|
||||
def get_task_from_id(self, task_id):
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
task = RQ_Job.fetch(task_id, connection=get_redis_connection(config['connection_config']))
|
||||
if not task:
|
||||
raise Http404
|
||||
|
||||
@@ -19,6 +19,7 @@ REVISION_BUTTONS = """
|
||||
class ConfigRevisionTable(NetBoxTable):
|
||||
is_active = columns.BooleanColumn(
|
||||
verbose_name=_('Is Active'),
|
||||
accessor='active',
|
||||
false_mark=None
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
|
||||
26
netbox/core/tests/test_tables.py
Normal file
26
netbox/core/tests/test_tables.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from core.models import ObjectChange
|
||||
from core.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
class DataSourceTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DataSourceTable
|
||||
|
||||
|
||||
class DataFileTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DataFileTable
|
||||
|
||||
|
||||
class JobTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = JobTable
|
||||
|
||||
|
||||
class ObjectChangeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ObjectChangeTable
|
||||
queryset_sources = [
|
||||
('ObjectChangeListView', ObjectChange.objects.valid_models()),
|
||||
]
|
||||
|
||||
|
||||
class ConfigRevisionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConfigRevisionTable
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
from django_rq.settings import QUEUES_MAP
|
||||
from django_rq.settings import get_queues_map
|
||||
from django_rq.workers import get_worker
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.job import JobStatus
|
||||
@@ -189,7 +189,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_default(self):
|
||||
queue = get_queue('default')
|
||||
queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -198,7 +198,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_high(self):
|
||||
queue = get_queue('high')
|
||||
queue.enqueue(self.dummy_job_high)
|
||||
queue_index = QUEUES_MAP['high']
|
||||
queue_index = get_queues_map()['high']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -207,7 +207,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_finished(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = FinishedJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -218,7 +218,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_failed(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = FailedJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -229,7 +229,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_scheduled(self):
|
||||
queue = get_queue('default')
|
||||
queue.enqueue_at(datetime.now(), self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'scheduled']))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -238,7 +238,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
def test_background_tasks_list_deferred(self):
|
||||
queue = get_queue('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
|
||||
registry = DeferredJobRegistry(queue.name, queue.connection)
|
||||
registry.add(job, 2)
|
||||
@@ -335,7 +335,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
worker2 = get_worker('high')
|
||||
worker2.register_birth()
|
||||
|
||||
queue_index = QUEUES_MAP['default']
|
||||
queue_index = get_queues_map()['default']
|
||||
response = self.client.get(reverse('core:worker_list', args=[queue_index]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(str(worker1.name), str(response.content))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
|
||||
from django_rq.settings import get_queues_list, get_queues_map
|
||||
from django_rq.utils import get_jobs, stop_jobs
|
||||
from rq import requeue_job
|
||||
from rq.exceptions import NoSuchJobError
|
||||
@@ -31,7 +31,7 @@ def get_rq_jobs():
|
||||
"""
|
||||
jobs = set()
|
||||
|
||||
for queue in QUEUES_LIST:
|
||||
for queue in get_queues_list():
|
||||
queue = get_queue(queue['name'])
|
||||
jobs.update(queue.get_jobs())
|
||||
|
||||
@@ -78,13 +78,13 @@ def delete_rq_job(job_id):
|
||||
"""
|
||||
Delete the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
# Remove job id from queue and delete the actual job
|
||||
@@ -96,13 +96,13 @@ def requeue_rq_job(job_id):
|
||||
"""
|
||||
Requeue the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
|
||||
@@ -112,13 +112,13 @@ def enqueue_rq_job(job_id):
|
||||
"""
|
||||
Enqueue the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
try:
|
||||
@@ -144,13 +144,13 @@ def stop_rq_job(job_id):
|
||||
"""
|
||||
Stop the specified RQ job.
|
||||
"""
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
return stop_jobs(queue, job_id)[0]
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST, QUEUES_MAP
|
||||
from django_rq.settings import get_queues_list, get_queues_map
|
||||
from django_rq.utils import get_statistics
|
||||
from rq.exceptions import NoSuchJobError
|
||||
from rq.job import Job as RQ_Job
|
||||
@@ -524,13 +524,13 @@ class BackgroundTaskView(BaseRQView):
|
||||
|
||||
def get(self, request, job_id):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
try:
|
||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||
except NoSuchJobError:
|
||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
||||
|
||||
queue_index = QUEUES_MAP[job.origin]
|
||||
queue_index = get_queues_map()[job.origin]
|
||||
queue = get_queue_by_index(queue_index)
|
||||
|
||||
try:
|
||||
@@ -640,7 +640,7 @@ class WorkerView(BaseRQView):
|
||||
|
||||
def get(self, request, key):
|
||||
# all the RQ queues should use the same connection
|
||||
config = QUEUES_LIST[0]
|
||||
config = get_queues_list()[0]
|
||||
worker = Worker.find_by_key('rq:worker:' + key, connection=get_redis_connection(config['connection_config']))
|
||||
# Convert microseconds to milliseconds
|
||||
worker.total_working_time = worker.total_working_time / 1000
|
||||
|
||||
@@ -38,7 +38,15 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
def get_connected_endpoints_reachable(self, obj):
|
||||
return obj._path and obj._path.is_complete and obj._path.is_active
|
||||
"""
|
||||
Return whether the connected endpoints are reachable via a complete, active cable path.
|
||||
"""
|
||||
# Use the public `path` accessor rather than dereferencing `_path`
|
||||
# directly. `path` already handles the stale in-memory relation case
|
||||
# that can occur while CablePath rows are rebuilt during cable edits.
|
||||
if path := obj.path:
|
||||
return path.is_complete and path.is_active
|
||||
return False
|
||||
|
||||
|
||||
class PortSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -6,8 +6,9 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
|
||||
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
|
||||
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
|
||||
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
|
||||
from ipam.api.serializers_.ip import IPAddressSerializer
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
|
||||
@@ -159,6 +160,60 @@ class ModuleSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
if self.nested:
|
||||
return data
|
||||
|
||||
# Skip validation for existing modules (updates)
|
||||
if self.instance is not None:
|
||||
return data
|
||||
|
||||
module_bay = data.get('module_bay')
|
||||
module_type = data.get('module_type')
|
||||
device = data.get('device')
|
||||
|
||||
if not all((module_bay, module_type, device)):
|
||||
return data
|
||||
|
||||
positions = get_module_bay_positions(module_bay)
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports"),
|
||||
]:
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
for template in getattr(module_type, templates).all():
|
||||
resolved_name = template.name
|
||||
if MODULE_TOKEN in template.name:
|
||||
if not module_bay.position:
|
||||
raise serializers.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
try:
|
||||
resolved_name = resolve_module_placeholder(template.name, positions)
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(str(e))
|
||||
|
||||
if resolved_name in installed_components:
|
||||
raise serializers.ValidationError(
|
||||
_("A {model} named {name} already exists").format(
|
||||
model=template.component_model.__name__,
|
||||
name=resolved_name
|
||||
)
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class MACAddressSerializer(PrimaryModelSerializer):
|
||||
assigned_object_type = ContentTypeField(
|
||||
|
||||
@@ -254,6 +254,21 @@ class Trunk8C4PCableProfile(BaseCableProfile):
|
||||
b_connectors = a_connectors
|
||||
|
||||
|
||||
class Breakout1C2Px2C1PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 2,
|
||||
}
|
||||
b_connectors = {
|
||||
1: 1,
|
||||
2: 1,
|
||||
}
|
||||
_mapping = {
|
||||
(1, 1): (1, 1),
|
||||
(1, 2): (2, 1),
|
||||
(2, 1): (1, 2),
|
||||
}
|
||||
|
||||
|
||||
class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
|
||||
@@ -1776,6 +1776,7 @@ class CableProfileChoices(ChoiceSet):
|
||||
TRUNK_4C8P = 'trunk-4c8p'
|
||||
TRUNK_8C4P = 'trunk-8c4p'
|
||||
# Breakouts
|
||||
BREAKOUT_1C2P_2C1P = 'breakout-1c2p-2c1p'
|
||||
BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
|
||||
BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
|
||||
BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
|
||||
@@ -1815,6 +1816,7 @@ class CableProfileChoices(ChoiceSet):
|
||||
(
|
||||
_('Breakout'),
|
||||
(
|
||||
(BREAKOUT_1C2P_2C1P, _('1C2P:2C1P breakout')),
|
||||
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
|
||||
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
|
||||
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from utilities.forms import get_field_value
|
||||
|
||||
__all__ = (
|
||||
@@ -70,18 +71,6 @@ class InterfaceCommonForm(forms.Form):
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
|
||||
def _get_module_bay_tree(self, module_bay):
|
||||
module_bays = []
|
||||
while module_bay:
|
||||
module_bays.append(module_bay)
|
||||
if module_bay.module:
|
||||
module_bay = module_bay.module.module_bay
|
||||
else:
|
||||
module_bay = None
|
||||
|
||||
module_bays.reverse()
|
||||
return module_bays
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -100,7 +89,7 @@ class ModuleCommonForm(forms.Form):
|
||||
self.instance._disable_replication = True
|
||||
return
|
||||
|
||||
module_bays = self._get_module_bay_tree(module_bay)
|
||||
positions = get_module_bay_positions(module_bay)
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
@@ -119,25 +108,16 @@ class ModuleCommonForm(forms.Form):
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
resolved_name = template.name
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name:
|
||||
if not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
_("Cannot install module with placeholder values in a module bay with no position defined.")
|
||||
)
|
||||
|
||||
if len(module_bays) != template.name.count(MODULE_TOKEN):
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"Cannot install module with placeholder values in a module bay tree {level} in tree "
|
||||
"but {tokens} placeholders given."
|
||||
).format(
|
||||
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
|
||||
)
|
||||
)
|
||||
|
||||
for module_bay in module_bays:
|
||||
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
|
||||
try:
|
||||
resolved_name = resolve_module_placeholder(template.name, positions)
|
||||
except ValueError as e:
|
||||
raise forms.ValidationError(str(e))
|
||||
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class Cable(PrimaryModel):
|
||||
CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
|
||||
CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
|
||||
|
||||
@@ -9,6 +9,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models.base import PortMappingBase
|
||||
from dcim.models.mixins import InterfaceValidationMixin
|
||||
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
@@ -165,31 +166,15 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
_("A component template must be associated with either a device type or a module type.")
|
||||
)
|
||||
|
||||
def _get_module_tree(self, module):
|
||||
modules = []
|
||||
while module:
|
||||
modules.append(module)
|
||||
if module.module_bay:
|
||||
module = module.module_bay.module
|
||||
else:
|
||||
module = None
|
||||
|
||||
modules.reverse()
|
||||
return modules
|
||||
|
||||
def _resolve_module_placeholder(self, value, module):
|
||||
if MODULE_TOKEN not in value or not module:
|
||||
return value
|
||||
modules = self._get_module_tree(module)
|
||||
for m in modules:
|
||||
value = value.replace(MODULE_TOKEN, m.module_bay.position, 1)
|
||||
return value
|
||||
|
||||
def resolve_name(self, module):
|
||||
return self._resolve_module_placeholder(self.name, module)
|
||||
if MODULE_TOKEN not in self.name or not module:
|
||||
return self.name
|
||||
return resolve_module_placeholder(self.name, get_module_bay_positions(module.module_bay))
|
||||
|
||||
def resolve_label(self, module):
|
||||
return self._resolve_module_placeholder(self.label, module)
|
||||
if MODULE_TOKEN not in self.label or not module:
|
||||
return self.label
|
||||
return resolve_module_placeholder(self.label, get_module_bay_positions(module.module_bay))
|
||||
|
||||
|
||||
class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
@@ -720,7 +705,9 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def resolve_position(self, module):
|
||||
return self._resolve_module_placeholder(self.position, module)
|
||||
if MODULE_TOKEN not in self.position or not module:
|
||||
return self.position
|
||||
return resolve_module_placeholder(self.position, get_module_bay_positions(module.module_bay))
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
|
||||
@@ -2,7 +2,7 @@ from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@@ -307,11 +307,12 @@ class PathEndpoint(models.Model):
|
||||
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -323,11 +324,14 @@ class PathEndpoint(models.Model):
|
||||
|
||||
# Construct the complete path (including e.g. bridged interfaces)
|
||||
while origin is not None:
|
||||
|
||||
if origin._path is None:
|
||||
# Go through the public accessor rather than dereferencing `_path`
|
||||
# directly. During cable edits, CablePath rows can be deleted and
|
||||
# recreated while this endpoint instance is still in memory.
|
||||
cable_path = origin.path
|
||||
if cable_path is None:
|
||||
break
|
||||
|
||||
path.extend(origin._path.path_objects)
|
||||
path.extend(cable_path.path_objects)
|
||||
|
||||
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
|
||||
if len(path) % 3 == 1:
|
||||
@@ -336,8 +340,8 @@ class PathEndpoint(models.Model):
|
||||
elif len(path) % 3 == 2:
|
||||
path.insert(-1, [])
|
||||
|
||||
# Check for a bridged relationship to continue the trace
|
||||
destinations = origin._path.destinations
|
||||
# Check for a bridged relationship to continue the trace.
|
||||
destinations = cable_path.destinations
|
||||
if len(destinations) == 1:
|
||||
origin = getattr(destinations[0], 'bridge', None)
|
||||
else:
|
||||
@@ -348,14 +352,42 @@ class PathEndpoint(models.Model):
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._path
|
||||
"""
|
||||
Return this endpoint's current CablePath, if any.
|
||||
|
||||
`_path` is a denormalized reference that is updated from CablePath
|
||||
save/delete handlers, including queryset.update() calls on origin
|
||||
endpoints. That means an already-instantiated endpoint can briefly hold
|
||||
a stale in-memory `_path` relation while the database already points to
|
||||
a different CablePath (or to no path at all).
|
||||
|
||||
If the cached relation points to a CablePath that has just been
|
||||
deleted, refresh only the `_path` field from the database and retry.
|
||||
This keeps the fix cheap and narrowly scoped to the denormalized FK.
|
||||
"""
|
||||
if self._path_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._path
|
||||
except ObjectDoesNotExist:
|
||||
# Refresh only the denormalized FK instead of the whole model.
|
||||
# The expected problem here is in-memory staleness during path
|
||||
# rebuilds, not persistent database corruption.
|
||||
self.refresh_from_db(fields=['_path'])
|
||||
return self._path if self._path_id else None
|
||||
|
||||
@cached_property
|
||||
def connected_endpoints(self):
|
||||
"""
|
||||
Caching accessor for the attached CablePath's destination (if any)
|
||||
Caching accessor for the attached CablePath's destinations (if any).
|
||||
|
||||
Always route through `path` so stale in-memory `_path` references are
|
||||
repaired before we cache the result for the lifetime of this instance.
|
||||
"""
|
||||
return self._path.destinations if self._path else []
|
||||
if cable_path := self.path:
|
||||
return cable_path.destinations
|
||||
return []
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1149,7 +1149,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, PrimaryModelTable):
|
||||
)
|
||||
device = tables.Column(
|
||||
verbose_name=_('Device'),
|
||||
order_by=('device___name',),
|
||||
order_by=('device__name',),
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
|
||||
@@ -56,7 +56,9 @@ class ModuleTypeTable(PrimaryModelTable):
|
||||
template_code=WEIGHT,
|
||||
order_by=('_abs_weight', 'weight_unit')
|
||||
)
|
||||
attributes = columns.DictColumn()
|
||||
attributes = columns.DictColumn(
|
||||
orderable=False,
|
||||
)
|
||||
module_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:module_list',
|
||||
url_params={'module_type_id': 'pk'},
|
||||
|
||||
@@ -5,6 +5,7 @@ from circuits.models import *
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.events import serialize_for_event
|
||||
from extras.models import CustomField
|
||||
from ipam.models import Prefix
|
||||
from netbox.choices import WeightUnitChoices
|
||||
@@ -893,6 +894,77 @@ class ModuleBayTestCase(TestCase):
|
||||
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
|
||||
self.assertEqual(nested_bay.position, '1-1')
|
||||
|
||||
@tag('regression') # #20474
|
||||
def test_single_module_token_at_nested_depth(self):
|
||||
"""
|
||||
A module type with a single {module} token should install at depth > 1
|
||||
without raising a token count mismatch error, resolving to the immediate
|
||||
parent bay's position.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Chassis with Rear Card',
|
||||
slug='chassis-with-rear-card'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Rear card slot',
|
||||
position='1'
|
||||
)
|
||||
|
||||
rear_card_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Rear Card'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=rear_card_type,
|
||||
name='SFP slot 1',
|
||||
position='1'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=rear_card_type,
|
||||
name='SFP slot 2',
|
||||
position='2'
|
||||
)
|
||||
|
||||
sfp_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='SFP Module'
|
||||
)
|
||||
InterfaceTemplate.objects.create(
|
||||
module_type=sfp_type,
|
||||
name='SFP {module}',
|
||||
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
|
||||
device = Device.objects.create(
|
||||
name='Test Chassis',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
rear_card_bay = device.modulebays.get(name='Rear card slot')
|
||||
rear_card = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=rear_card_bay,
|
||||
module_type=rear_card_type
|
||||
)
|
||||
|
||||
sfp_bay = rear_card.modulebays.get(name='SFP slot 2')
|
||||
sfp_module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=sfp_bay,
|
||||
module_type=sfp_type
|
||||
)
|
||||
|
||||
interface = sfp_module.interfaces.first()
|
||||
self.assertEqual(interface.name, 'SFP 2')
|
||||
|
||||
@tag('regression') # #20912
|
||||
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||
@@ -1274,6 +1346,65 @@ class CableTestCase(TestCase):
|
||||
self.assertEqual(a_terms, [interface1])
|
||||
self.assertEqual(b_terms, [interface2])
|
||||
|
||||
@tag('regression') # #21498
|
||||
def test_path_refreshes_replaced_cablepath_reference(self):
|
||||
"""
|
||||
An already-instantiated interface should refresh its denormalized
|
||||
`_path` foreign key when the referenced CablePath row has been
|
||||
replaced in the database.
|
||||
"""
|
||||
stale_interface = Interface.objects.get(device__name='TestDevice1', name='eth0')
|
||||
old_path = CablePath.objects.get(pk=stale_interface._path_id)
|
||||
|
||||
new_path = CablePath(
|
||||
path=old_path.path,
|
||||
is_active=old_path.is_active,
|
||||
is_complete=old_path.is_complete,
|
||||
is_split=old_path.is_split,
|
||||
)
|
||||
old_path_id = old_path.pk
|
||||
old_path.delete()
|
||||
new_path.save()
|
||||
|
||||
# The old CablePath no longer exists
|
||||
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
|
||||
|
||||
# The already-instantiated interface still points to the deleted path
|
||||
# until the accessor refreshes `_path` from the database.
|
||||
self.assertEqual(stale_interface._path_id, old_path_id)
|
||||
self.assertEqual(stale_interface.path.pk, new_path.pk)
|
||||
|
||||
@tag('regression') # #21498
|
||||
def test_serialize_for_event_handles_stale_cablepath_reference_after_retermination(self):
|
||||
"""
|
||||
Serializing an interface whose previously cached `_path` row has been
|
||||
deleted during cable retermination must not raise.
|
||||
"""
|
||||
stale_interface = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
old_path_id = stale_interface._path_id
|
||||
new_peer = Interface.objects.get(device__name='TestDevice2', name='eth1')
|
||||
cable = stale_interface.cable
|
||||
|
||||
self.assertIsNotNone(cable)
|
||||
self.assertIsNotNone(old_path_id)
|
||||
self.assertEqual(stale_interface.cable_end, 'B')
|
||||
|
||||
cable.b_terminations = [new_peer]
|
||||
cable.save()
|
||||
|
||||
# The old CablePath was deleted during retrace.
|
||||
self.assertFalse(CablePath.objects.filter(pk=old_path_id).exists())
|
||||
|
||||
# The stale in-memory instance still holds the deleted FK value.
|
||||
self.assertEqual(stale_interface._path_id, old_path_id)
|
||||
|
||||
# Serialization must not raise ObjectDoesNotExist. Because this interface
|
||||
# was the former B-side termination, it is now disconnected.
|
||||
data = serialize_for_event(stale_interface)
|
||||
self.assertIsNone(data['connected_endpoints'])
|
||||
self.assertIsNone(data['connected_endpoints_type'])
|
||||
self.assertFalse(data['connected_endpoints_reachable'])
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
|
||||
204
netbox/dcim/tests/test_tables.py
Normal file
204
netbox/dcim/tests/test_tables.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from dcim.models import ConsolePort, Interface, PowerPort
|
||||
from dcim.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
|
||||
class RegionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RegionTable
|
||||
|
||||
|
||||
class SiteGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = SiteGroupTable
|
||||
|
||||
|
||||
class SiteTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = SiteTable
|
||||
|
||||
|
||||
class LocationTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = LocationTable
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackRoleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RackRoleTable
|
||||
|
||||
|
||||
class RackTypeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RackTypeTable
|
||||
|
||||
|
||||
class RackTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RackTable
|
||||
|
||||
|
||||
class RackReservationTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RackReservationTable
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
|
||||
class ManufacturerTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ManufacturerTable
|
||||
|
||||
|
||||
class DeviceTypeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DeviceTypeTable
|
||||
|
||||
|
||||
#
|
||||
# Module types
|
||||
#
|
||||
|
||||
class ModuleTypeProfileTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ModuleTypeProfileTable
|
||||
|
||||
|
||||
class ModuleTypeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ModuleTypeTable
|
||||
|
||||
|
||||
class ModuleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ModuleTable
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceRoleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DeviceRoleTable
|
||||
|
||||
|
||||
class PlatformTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PlatformTable
|
||||
|
||||
|
||||
class DeviceTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DeviceTable
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConsolePortTable
|
||||
|
||||
|
||||
class ConsoleServerPortTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConsoleServerPortTable
|
||||
|
||||
|
||||
class PowerPortTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PowerPortTable
|
||||
|
||||
|
||||
class PowerOutletTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PowerOutletTable
|
||||
|
||||
|
||||
class InterfaceTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = InterfaceTable
|
||||
|
||||
|
||||
class FrontPortTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = FrontPortTable
|
||||
|
||||
|
||||
class RearPortTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RearPortTable
|
||||
|
||||
|
||||
class ModuleBayTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ModuleBayTable
|
||||
|
||||
|
||||
class DeviceBayTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = DeviceBayTable
|
||||
|
||||
|
||||
class InventoryItemTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = InventoryItemTable
|
||||
|
||||
|
||||
class InventoryItemRoleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = InventoryItemRoleTable
|
||||
|
||||
|
||||
#
|
||||
# Connections
|
||||
#
|
||||
|
||||
class ConsoleConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConsoleConnectionTable
|
||||
queryset_sources = [
|
||||
('ConsoleConnectionsListView', ConsolePort.objects.filter(_path__is_complete=True)),
|
||||
]
|
||||
|
||||
|
||||
class PowerConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PowerConnectionTable
|
||||
queryset_sources = [
|
||||
('PowerConnectionsListView', PowerPort.objects.filter(_path__is_complete=True)),
|
||||
]
|
||||
|
||||
|
||||
class InterfaceConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = InterfaceConnectionTable
|
||||
queryset_sources = [
|
||||
('InterfaceConnectionsListView', Interface.objects.filter(_path__is_complete=True)),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CableTable
|
||||
|
||||
|
||||
#
|
||||
# Power
|
||||
#
|
||||
|
||||
class PowerPanelTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PowerPanelTable
|
||||
|
||||
|
||||
class PowerFeedTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PowerFeedTable
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VirtualChassisTable
|
||||
|
||||
|
||||
#
|
||||
# Virtual device contexts
|
||||
#
|
||||
|
||||
class VirtualDeviceContextTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VirtualDeviceContextTable
|
||||
|
||||
|
||||
#
|
||||
# MAC addresses
|
||||
#
|
||||
|
||||
class MACAddressTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = MACAddressTable
|
||||
@@ -420,14 +420,23 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel which lists all members of a virtual chassis.
|
||||
"""
|
||||
|
||||
template_name = 'dcim/panels/virtual_chassis_members.html'
|
||||
title = _('Virtual Chassis Members')
|
||||
actions = [
|
||||
actions.AddObject(
|
||||
'dcim.device',
|
||||
url_params={
|
||||
'site': lambda ctx: ctx['object'].master.site_id if ctx['object'].master else '',
|
||||
'rack': lambda ctx: ctx['object'].master.rack_id if ctx['object'].master else '',
|
||||
'site': lambda ctx: (
|
||||
ctx['virtual_chassis'].master.site_id
|
||||
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
|
||||
else ''
|
||||
),
|
||||
'rack': lambda ctx: (
|
||||
ctx['virtual_chassis'].master.rack_id
|
||||
if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
|
||||
else ''
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -532,7 +541,7 @@ class VirtualCircuitPanel(panels.ObjectPanel):
|
||||
|
||||
def render(self, context):
|
||||
obj = context.get('object')
|
||||
if not obj or not obj.is_virtual or not obj.virtual_circuit_termination:
|
||||
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
|
||||
return ''
|
||||
ctx = self.get_context(context)
|
||||
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
|
||||
|
||||
@@ -3,6 +3,9 @@ from collections import defaultdict
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import router, transaction
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
|
||||
|
||||
def compile_path_node(ct_id, object_id):
|
||||
@@ -33,6 +36,51 @@ def path_node_to_object(repr):
|
||||
return ct.model_class().objects.filter(pk=object_id).first()
|
||||
|
||||
|
||||
def get_module_bay_positions(module_bay):
|
||||
"""
|
||||
Given a module bay, traverse up the module hierarchy and return
|
||||
a list of bay position strings from root to leaf.
|
||||
"""
|
||||
positions = []
|
||||
while module_bay:
|
||||
positions.append(module_bay.position)
|
||||
if module_bay.module:
|
||||
module_bay = module_bay.module.module_bay
|
||||
else:
|
||||
module_bay = None
|
||||
positions.reverse()
|
||||
return positions
|
||||
|
||||
|
||||
def resolve_module_placeholder(value, positions):
|
||||
"""
|
||||
Resolve {module} placeholder tokens in a string using the given
|
||||
list of module bay positions (ordered root to leaf).
|
||||
|
||||
A single {module} token resolves to the leaf (immediate parent) bay's position.
|
||||
Multiple tokens must match the tree depth and resolve level-by-level.
|
||||
|
||||
Returns the resolved string.
|
||||
Raises ValueError if token count is greater than 1 and doesn't match tree depth.
|
||||
"""
|
||||
if MODULE_TOKEN not in value:
|
||||
return value
|
||||
|
||||
token_count = value.count(MODULE_TOKEN)
|
||||
if token_count == 1:
|
||||
return value.replace(MODULE_TOKEN, positions[-1])
|
||||
if token_count == len(positions):
|
||||
for pos in positions:
|
||||
value = value.replace(MODULE_TOKEN, pos, 1)
|
||||
return value
|
||||
raise ValueError(
|
||||
_("Cannot install module with placeholder values in a module bay tree "
|
||||
"{level} levels deep but {tokens} placeholders given.").format(
|
||||
level=len(positions), tokens=token_count
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_cablepaths(objects):
|
||||
"""
|
||||
Create CablePaths for all paths originating from the specified set of nodes.
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.fields import Field
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
@@ -49,8 +49,25 @@ class CustomFieldsDataField(Field):
|
||||
# TODO: Fix circular import
|
||||
from utilities.api import get_serializer_for_model
|
||||
data = {}
|
||||
cache = self.parent.context.get('cf_object_cache')
|
||||
|
||||
for cf in self._get_custom_fields():
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
if cache is not None and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
):
|
||||
raw = obj.get(cf.name)
|
||||
if raw is None:
|
||||
value = None
|
||||
elif cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = cf.related_object_type.model_class()
|
||||
value = cache.get((model, raw))
|
||||
else:
|
||||
model = cf.related_object_type.model_class()
|
||||
value = [cache[(model, pk)] for pk in raw if (model, pk) in cache] or None
|
||||
else:
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
|
||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
serializer = get_serializer_for_model(cf.related_object_type.model_class())
|
||||
value = serializer(value, nested=True, context=self.parent.context).data
|
||||
@@ -87,3 +104,32 @@ class CustomFieldsDataField(Field):
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CustomFieldListSerializer(ListSerializer):
|
||||
"""
|
||||
ListSerializer that pre-fetches all OBJECT/MULTIOBJECT custom field related objects
|
||||
in bulk before per-item serialization.
|
||||
"""
|
||||
def to_representation(self, data):
|
||||
cf_field = self.child.fields.get('custom_fields')
|
||||
if isinstance(cf_field, CustomFieldsDataField):
|
||||
object_type_cfs = [
|
||||
cf for cf in cf_field._get_custom_fields()
|
||||
if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
|
||||
]
|
||||
cache = {}
|
||||
for cf in object_type_cfs:
|
||||
model = cf.related_object_type.model_class()
|
||||
pks = set()
|
||||
for item in data:
|
||||
raw = item.custom_field_data.get(cf.name)
|
||||
if raw is not None:
|
||||
if cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
pks.update(raw)
|
||||
else:
|
||||
pks.add(raw)
|
||||
for obj in model.objects.filter(pk__in=pks):
|
||||
cache[(model, obj.pk)] = obj
|
||||
self.child.context['cf_object_cache'] = cache
|
||||
return super().to_representation(data)
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
from django.utils.translation import gettext as _
|
||||
import logging
|
||||
|
||||
from django.core.files.storage import storages
|
||||
from django.db import IntegrityError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.serializers_.jobs import JobSerializer
|
||||
from extras.models import Script
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from extras.models import Script, ScriptModule
|
||||
from netbox.api.serializers import ValidatedModelSerializer
|
||||
from utilities.datetime import local_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = (
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
'ScriptModuleSerializer',
|
||||
'ScriptSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ScriptModuleSerializer(ValidatedModelSerializer):
|
||||
file = serializers.FileField(write_only=True)
|
||||
file_path = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ScriptModule
|
||||
fields = ['id', 'display', 'file_path', 'file', 'created', 'last_updated']
|
||||
brief_fields = ('id', 'display')
|
||||
|
||||
def validate(self, data):
|
||||
# ScriptModule.save() sets file_root; inject it here so full_clean() succeeds.
|
||||
# Pop 'file' before model instantiation — ScriptModule has no such field.
|
||||
file = data.pop('file', None)
|
||||
data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
|
||||
data = super().validate(data)
|
||||
data.pop('file_root', None)
|
||||
if file is not None:
|
||||
data['file'] = file
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
file = validated_data.pop('file')
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
validated_data['file_path'] = storage.save(file.name, file)
|
||||
created = False
|
||||
try:
|
||||
instance = super().create(validated_data)
|
||||
created = True
|
||||
return instance
|
||||
except IntegrityError as e:
|
||||
if 'file_path' in str(e):
|
||||
raise serializers.ValidationError(
|
||||
_("A script module with this file name already exists.")
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if not created and (file_path := validated_data.get('file_path')):
|
||||
try:
|
||||
storage.delete(file_path)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
|
||||
|
||||
|
||||
class ScriptSerializer(ValidatedModelSerializer):
|
||||
description = serializers.SerializerMethodField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@@ -26,6 +26,7 @@ router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-context-profiles', views.ConfigContextProfileViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('scripts/upload', views.ScriptModuleViewSet)
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
|
||||
app_name = 'extras-api'
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
@@ -21,6 +21,7 @@ from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
|
||||
from netbox.api.viewsets.mixins import ObjectValidationMixin
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.request import copy_safe_request
|
||||
|
||||
@@ -264,6 +265,11 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, BaseViewSet):
|
||||
queryset = ScriptModule.objects.all()
|
||||
serializer_class = serializers.ScriptModuleSerializer
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
update=extend_schema(request=serializers.ScriptInputSerializer),
|
||||
partial_update=extend_schema(request=serializers.ScriptInputSerializer),
|
||||
|
||||
@@ -25,16 +25,54 @@ logger = logging.getLogger('netbox.events_processor')
|
||||
|
||||
class EventContext(UserDict):
|
||||
"""
|
||||
A custom dictionary that automatically serializes its associated object on demand.
|
||||
Dictionary-compatible wrapper for queued events that lazily serializes
|
||||
``event['data']`` on first access.
|
||||
|
||||
Backward-compatible with the plain-dict interface expected by existing
|
||||
EVENTS_PIPELINE consumers. When the same object is enqueued more than once
|
||||
in a single request, the serialization source is updated so consumers see
|
||||
the latest state.
|
||||
"""
|
||||
|
||||
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
|
||||
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
|
||||
# changes until a suitable minor release.
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Track which model instance should be serialized if/when `data` is
|
||||
# requested. This may be refreshed on duplicate enqueue, while leaving
|
||||
# the public `object` entry untouched for compatibility.
|
||||
self._serialization_source = None
|
||||
if 'object' in self:
|
||||
self._serialization_source = super().__getitem__('object')
|
||||
|
||||
def refresh_serialization_source(self, instance):
|
||||
"""
|
||||
Point lazy serialization at a fresher instance, invalidating any
|
||||
already-materialized ``data``.
|
||||
"""
|
||||
self._serialization_source = instance
|
||||
# UserDict.__contains__ checks the backing dict directly, so `in`
|
||||
# does not trigger __getitem__'s lazy serialization.
|
||||
if 'data' in self:
|
||||
del self['data']
|
||||
|
||||
def freeze_data(self, instance):
|
||||
"""
|
||||
Eagerly serialize and cache the payload for delete events, where the
|
||||
object may become inaccessible after deletion.
|
||||
"""
|
||||
super().__setitem__('data', serialize_for_event(instance))
|
||||
self._serialization_source = None
|
||||
|
||||
def __getitem__(self, item):
|
||||
if item == 'data' and 'data' not in self:
|
||||
data = serialize_for_event(self['object'])
|
||||
self.__setitem__('data', data)
|
||||
# Materialize the payload only when an event consumer asks for it.
|
||||
#
|
||||
# On coalesced events, use the latest explicitly queued instance so
|
||||
# webhooks/scripts/notifications observe the final queued state for
|
||||
# that object within the request.
|
||||
source = self._serialization_source or super().__getitem__('object')
|
||||
super().__setitem__('data', serialize_for_event(source))
|
||||
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
@@ -76,8 +114,9 @@ def get_snapshots(instance, event_type):
|
||||
|
||||
def enqueue_event(queue, instance, request, event_type):
|
||||
"""
|
||||
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||
events once the request has completed.
|
||||
Enqueue (or coalesce) an event for a created/updated/deleted object.
|
||||
|
||||
Events are processed after the request completes.
|
||||
"""
|
||||
# Bail if this type of object does not support event rules
|
||||
if not has_feature(instance, 'event_rules'):
|
||||
@@ -88,11 +127,18 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
|
||||
assert instance.pk is not None
|
||||
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||
|
||||
if key in queue:
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
|
||||
# If the object is being deleted, update any prior "update" event to "delete"
|
||||
|
||||
# If the object is being deleted, convert any prior update event into a
|
||||
# delete event and freeze the payload before the object (or related
|
||||
# rows) become inaccessible.
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['event_type'] = event_type
|
||||
else:
|
||||
# Keep the public `object` entry stable for compatibility.
|
||||
queue[key].refresh_serialization_source(instance)
|
||||
else:
|
||||
queue[key] = EventContext(
|
||||
object_type=ObjectType.objects.get_for_model(instance),
|
||||
@@ -106,9 +152,11 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
username=request.user.username, # DEPRECATED, will be removed in NetBox v4.7.0
|
||||
request_id=request.id, # DEPRECATED, will be removed in NetBox v4.7.0
|
||||
)
|
||||
# Force serialization of objects prior to them actually being deleted
|
||||
|
||||
# For delete events, eagerly serialize the payload before the row is gone.
|
||||
# This covers both first-time enqueues and coalesced update→delete promotions.
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
queue[key].freeze_data(instance)
|
||||
|
||||
|
||||
def process_event_rules(event_rules, object_type, event):
|
||||
@@ -133,9 +181,9 @@ def process_event_rules(event_rules, object_type, event):
|
||||
if not event_rule.eval_conditions(event['data']):
|
||||
continue
|
||||
|
||||
# Compile event data
|
||||
event_data = event_rule.action_data or {}
|
||||
event_data.update(event['data'])
|
||||
# Merge rule-specific action_data with the event payload.
|
||||
# Copy to avoid mutating the rule's stored action_data dict.
|
||||
event_data = {**(event_rule.action_data or {}), **event['data']}
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
|
||||
@@ -74,7 +74,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return custom_fields
|
||||
|
||||
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
|
||||
custom_fields = self.get_queryset().filter(object_types=content_type)
|
||||
custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type')
|
||||
|
||||
# Populate the request cache to avoid redundant lookups
|
||||
if cache is not None:
|
||||
|
||||
@@ -417,6 +417,7 @@ class NotificationTable(NetBoxTable):
|
||||
icon = columns.TemplateColumn(
|
||||
template_code=NOTIFICATION_ICON,
|
||||
accessor=tables.A('event'),
|
||||
orderable=False,
|
||||
attrs={
|
||||
'td': {'class': 'w-1'},
|
||||
'th': {'class': 'w-1'},
|
||||
@@ -479,8 +480,8 @@ class WebhookTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
ssl_validation = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Validation')
|
||||
ssl_verification = columns.BooleanColumn(
|
||||
verbose_name=_('SSL Verification'),
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware, now
|
||||
from rest_framework import status
|
||||
@@ -1384,3 +1386,54 @@ class NotificationTest(APIViewTestCases.APIViewTestCase):
|
||||
'event_type': OBJECT_DELETED,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ScriptModuleTest(APITestCase):
|
||||
"""
|
||||
Tests for the POST /api/extras/scripts/upload/ endpoint.
|
||||
|
||||
ScriptModule is a proxy of core.ManagedFile (a different app) so the standard
|
||||
APIViewTestCases mixins cannot be used directly. All tests use add_permissions()
|
||||
with explicit Django model-level permissions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse('extras-api:scriptmodule-list') # /api/extras/scripts/upload/
|
||||
|
||||
def test_upload_script_module_without_permission(self):
|
||||
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
|
||||
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'file': upload_file},
|
||||
format='multipart',
|
||||
**self.header,
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_upload_script_module(self):
|
||||
# ScriptModule is a proxy of core.ManagedFile; both permissions required.
|
||||
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
|
||||
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
|
||||
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.save.return_value = 'test_upload.py'
|
||||
with patch('extras.api.serializers_.scripts.storages') as mock_storages:
|
||||
mock_storages.create_storage.return_value = mock_storage
|
||||
mock_storages.backends = {'scripts': {}}
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'file': upload_file},
|
||||
format='multipart',
|
||||
**self.header,
|
||||
)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['file_path'], 'test_upload.py')
|
||||
mock_storage.save.assert_called_once()
|
||||
self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists())
|
||||
|
||||
def test_upload_script_module_without_file_fails(self):
|
||||
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
|
||||
response = self.client.post(self.url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import uuid
|
||||
from unittest import skipIf
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
@@ -343,6 +345,7 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
|
||||
self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
|
||||
def test_send_webhook(self):
|
||||
request_id = uuid.uuid4()
|
||||
|
||||
@@ -426,6 +429,97 @@ class EventRuleTest(APITestCase):
|
||||
self.assertEqual(job.kwargs['object_type'], script_type)
|
||||
self.assertEqual(job.kwargs['username'], self.user.username)
|
||||
|
||||
def test_duplicate_enqueue_refreshes_lazy_payload(self):
|
||||
"""
|
||||
When the same object is enqueued more than once in a single request,
|
||||
lazy serialization should use the most recently enqueued instance while
|
||||
preserving the original event['object'] reference.
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
stale_site = Site.objects.get(pk=site.pk)
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, stale_site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
# Data should not be materialized yet (lazy serialization)
|
||||
self.assertNotIn('data', event.data)
|
||||
|
||||
fresh_site = Site.objects.get(pk=site.pk)
|
||||
fresh_site.description = 'foo'
|
||||
fresh_site.save()
|
||||
|
||||
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
|
||||
|
||||
# The original object reference should be preserved
|
||||
self.assertIs(event['object'], stale_site)
|
||||
|
||||
# But serialized data should reflect the fresher instance
|
||||
self.assertEqual(event['data']['description'], 'foo')
|
||||
self.assertEqual(event['snapshots']['postchange']['description'], 'foo')
|
||||
|
||||
def test_duplicate_enqueue_invalidates_materialized_data(self):
|
||||
"""
|
||||
If event['data'] has already been materialized before a second enqueue
|
||||
for the same object, the stale payload should be discarded and rebuilt
|
||||
from the fresher instance on next access.
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
# Force early materialization
|
||||
self.assertEqual(event['data']['description'], '')
|
||||
|
||||
# Now update and re-enqueue
|
||||
fresh_site = Site.objects.get(pk=site.pk)
|
||||
fresh_site.description = 'updated'
|
||||
fresh_site.save()
|
||||
|
||||
enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
|
||||
|
||||
# Stale data should have been invalidated; new access should reflect update
|
||||
self.assertEqual(event['data']['description'], 'updated')
|
||||
|
||||
def test_update_then_delete_enqueue_freezes_payload(self):
|
||||
"""
|
||||
When an update event is coalesced with a subsequent delete, the event
|
||||
type should be promoted to OBJECT_DELETED and the payload should be
|
||||
eagerly frozen (since the object will be inaccessible after deletion).
|
||||
"""
|
||||
request = RequestFactory().get(reverse('dcim:site_add'))
|
||||
request.id = uuid.uuid4()
|
||||
request.user = self.user
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
queue = {}
|
||||
enqueue_event(queue, site, request, OBJECT_UPDATED)
|
||||
|
||||
event = queue[f'dcim.site:{site.pk}']
|
||||
|
||||
enqueue_event(queue, site, request, OBJECT_DELETED)
|
||||
|
||||
# Event type should have been promoted
|
||||
self.assertEqual(event['event_type'], OBJECT_DELETED)
|
||||
|
||||
# Data should already be materialized (frozen), not lazy
|
||||
self.assertIn('data', event.data)
|
||||
self.assertEqual(event['data']['name'], 'Site 1')
|
||||
self.assertIsNone(event['snapshots']['postchange'])
|
||||
|
||||
def test_duplicate_triggers(self):
|
||||
"""
|
||||
Test for erroneous duplicate event triggers resulting from saving an object multiple times
|
||||
|
||||
@@ -1,24 +1,84 @@
|
||||
from django.test import RequestFactory, TestCase, tag
|
||||
|
||||
from extras.models import EventRule
|
||||
from extras.tables import EventRuleTable
|
||||
from extras.models import Bookmark, Notification, Subscription
|
||||
from extras.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
@tag('regression')
|
||||
class EventRuleTableTest(TestCase):
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
rule = EventRule.objects.all()
|
||||
disallowed = {
|
||||
'actions',
|
||||
}
|
||||
class CustomFieldTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CustomFieldTable
|
||||
|
||||
orderable_columns = [
|
||||
column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get('/')
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
table = EventRuleTable(rule)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
class CustomFieldChoiceSetTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CustomFieldChoiceSetTable
|
||||
|
||||
|
||||
class CustomLinkTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = CustomLinkTable
|
||||
|
||||
|
||||
class ExportTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ExportTemplateTable
|
||||
|
||||
|
||||
class SavedFilterTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = SavedFilterTable
|
||||
|
||||
|
||||
class TableConfigTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = TableConfigTable
|
||||
|
||||
|
||||
class BookmarkTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = BookmarkTable
|
||||
queryset_sources = [
|
||||
('BookmarkListView', Bookmark.objects.all()),
|
||||
]
|
||||
|
||||
|
||||
class NotificationGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = NotificationGroupTable
|
||||
|
||||
|
||||
class NotificationTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = NotificationTable
|
||||
queryset_sources = [
|
||||
('NotificationListView', Notification.objects.all()),
|
||||
]
|
||||
|
||||
|
||||
class SubscriptionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = SubscriptionTable
|
||||
queryset_sources = [
|
||||
('SubscriptionListView', Subscription.objects.all()),
|
||||
]
|
||||
|
||||
|
||||
class WebhookTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = WebhookTable
|
||||
|
||||
|
||||
class EventRuleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = EventRuleTable
|
||||
|
||||
|
||||
class TagTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = TagTable
|
||||
|
||||
|
||||
class ConfigContextProfileTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConfigContextProfileTable
|
||||
|
||||
|
||||
class ConfigContextTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConfigContextTable
|
||||
|
||||
|
||||
class ConfigTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ConfigTemplateTable
|
||||
|
||||
|
||||
class ImageAttachmentTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ImageAttachmentTable
|
||||
|
||||
|
||||
class JournalEntryTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = JournalEntryTable
|
||||
|
||||
@@ -2,16 +2,55 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.ui import actions, panels
|
||||
from netbox.ui import actions, attrs, panels
|
||||
from utilities.data import resolve_attr_path
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextAssignmentPanel',
|
||||
'ConfigContextPanel',
|
||||
'ConfigContextProfilePanel',
|
||||
'ConfigTemplatePanel',
|
||||
'CustomFieldBehaviorPanel',
|
||||
'CustomFieldChoiceSetChoicesPanel',
|
||||
'CustomFieldChoiceSetPanel',
|
||||
'CustomFieldObjectTypesPanel',
|
||||
'CustomFieldPanel',
|
||||
'CustomFieldRelatedObjectsPanel',
|
||||
'CustomFieldValidationPanel',
|
||||
'CustomFieldsPanel',
|
||||
'CustomLinkPanel',
|
||||
'EventRuleActionPanel',
|
||||
'EventRuleEventTypesPanel',
|
||||
'EventRulePanel',
|
||||
'ExportTemplatePanel',
|
||||
'ImageAttachmentFilePanel',
|
||||
'ImageAttachmentImagePanel',
|
||||
'ImageAttachmentPanel',
|
||||
'ImageAttachmentsPanel',
|
||||
'JournalEntryPanel',
|
||||
'NotificationGroupGroupsPanel',
|
||||
'NotificationGroupPanel',
|
||||
'NotificationGroupUsersPanel',
|
||||
'ObjectTypesPanel',
|
||||
'SavedFilterObjectTypesPanel',
|
||||
'SavedFilterPanel',
|
||||
'TableConfigColumnsPanel',
|
||||
'TableConfigOrderingPanel',
|
||||
'TableConfigPanel',
|
||||
'TagItemTypesPanel',
|
||||
'TagObjectTypesPanel',
|
||||
'TagPanel',
|
||||
'TagsPanel',
|
||||
'WebhookHTTPPanel',
|
||||
'WebhookPanel',
|
||||
'WebhookSSLPanel',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Generic panels
|
||||
#
|
||||
|
||||
class CustomFieldsPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel showing the value of all custom fields defined on an object.
|
||||
@@ -73,3 +112,403 @@ class TagsPanel(panels.ObjectPanel):
|
||||
**super().get_context(context),
|
||||
'object': resolve_attr_path(context, self.accessor),
|
||||
}
|
||||
|
||||
|
||||
class ObjectTypesPanel(panels.ObjectPanel):
|
||||
"""
|
||||
A panel listing the object types assigned to the object.
|
||||
"""
|
||||
template_name = 'extras/panels/object_types.html'
|
||||
title = _('Object Types')
|
||||
|
||||
|
||||
#
|
||||
# CustomField panels
|
||||
#
|
||||
|
||||
class CustomFieldPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Field')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
type = attrs.TemplatedAttr('type', label=_('Type'), template_name='extras/customfield/attrs/type.html')
|
||||
label = attrs.TextAttr('label')
|
||||
group_name = attrs.TextAttr('group_name', label=_('Group name'))
|
||||
description = attrs.TextAttr('description')
|
||||
required = attrs.BooleanAttr('required')
|
||||
unique = attrs.BooleanAttr('unique', label=_('Must be unique'))
|
||||
is_cloneable = attrs.BooleanAttr('is_cloneable', label=_('Cloneable'))
|
||||
choice_set = attrs.TemplatedAttr(
|
||||
'choice_set',
|
||||
template_name='extras/customfield/attrs/choice_set.html',
|
||||
)
|
||||
default = attrs.TextAttr('default', label=_('Default value'))
|
||||
related_object_filter = attrs.TemplatedAttr(
|
||||
'related_object_filter',
|
||||
template_name='extras/customfield/attrs/related_object_filter.html',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldBehaviorPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Behavior')
|
||||
|
||||
search_weight = attrs.TemplatedAttr(
|
||||
'search_weight',
|
||||
template_name='extras/customfield/attrs/search_weight.html',
|
||||
)
|
||||
filter_logic = attrs.ChoiceAttr('filter_logic')
|
||||
weight = attrs.NumericAttr('weight', label=_('Display weight'))
|
||||
ui_visible = attrs.ChoiceAttr('ui_visible', label=_('UI visible'))
|
||||
ui_editable = attrs.ChoiceAttr('ui_editable', label=_('UI editable'))
|
||||
|
||||
|
||||
class CustomFieldValidationPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Validation Rules')
|
||||
|
||||
validation_minimum = attrs.NumericAttr('validation_minimum', label=_('Minimum value'))
|
||||
validation_maximum = attrs.NumericAttr('validation_maximum', label=_('Maximum value'))
|
||||
validation_regex = attrs.TextAttr(
|
||||
'validation_regex',
|
||||
label=_('Regular expression'),
|
||||
style='font-monospace',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/object_types.html'
|
||||
title = _('Object Types')
|
||||
|
||||
|
||||
class CustomFieldRelatedObjectsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/customfield_related_objects.html'
|
||||
title = _('Related Objects')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'related_models': context.get('related_models'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CustomFieldChoiceSet panels
|
||||
#
|
||||
|
||||
class CustomFieldChoiceSetPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Field Choice Set')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
base_choices = attrs.ChoiceAttr('base_choices')
|
||||
order_alphabetically = attrs.BooleanAttr('order_alphabetically')
|
||||
choices_for = attrs.RelatedObjectListAttr('choices_for', linkify=True, label=_('Used by'))
|
||||
|
||||
|
||||
class CustomFieldChoiceSetChoicesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/customfieldchoiceset_choices.html'
|
||||
|
||||
def get_context(self, context):
|
||||
obj = context.get('object')
|
||||
total = len(obj.choices) if obj else 0
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'title': f'{_("Choices")} ({total})',
|
||||
'choices': context.get('choices'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CustomLink panels
|
||||
#
|
||||
|
||||
class CustomLinkPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Custom Link')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
group_name = attrs.TextAttr('group_name')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
button_class = attrs.ChoiceAttr('button_class')
|
||||
new_window = attrs.BooleanAttr('new_window')
|
||||
|
||||
|
||||
#
|
||||
# ExportTemplate panels
|
||||
#
|
||||
|
||||
class ExportTemplatePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Export Template')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
|
||||
file_name = attrs.TextAttr('file_name')
|
||||
file_extension = attrs.TextAttr('file_extension')
|
||||
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
|
||||
|
||||
|
||||
#
|
||||
# SavedFilter panels
|
||||
#
|
||||
|
||||
class SavedFilterPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Saved Filter')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
user = attrs.TextAttr('user')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
shared = attrs.BooleanAttr('shared')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
|
||||
|
||||
class SavedFilterObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/savedfilter_object_types.html'
|
||||
title = _('Assigned Models')
|
||||
|
||||
|
||||
#
|
||||
# TableConfig panels
|
||||
#
|
||||
|
||||
class TableConfigPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Table Config')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
object_type = attrs.TextAttr('object_type')
|
||||
table = attrs.TextAttr('table')
|
||||
user = attrs.TextAttr('user')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
shared = attrs.BooleanAttr('shared')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
|
||||
|
||||
class TableConfigColumnsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tableconfig_columns.html'
|
||||
title = _('Columns Displayed')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'columns': context.get('columns'),
|
||||
}
|
||||
|
||||
|
||||
class TableConfigOrderingPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tableconfig_ordering.html'
|
||||
title = _('Ordering')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'columns': context.get('columns'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# NotificationGroup panels
|
||||
#
|
||||
|
||||
class NotificationGroupPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Notification Group')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class NotificationGroupGroupsPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/notificationgroup_groups.html'
|
||||
title = _('Groups')
|
||||
|
||||
|
||||
class NotificationGroupUsersPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/notificationgroup_users.html'
|
||||
title = _('Users')
|
||||
|
||||
|
||||
#
|
||||
# Webhook panels
|
||||
#
|
||||
|
||||
class WebhookPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Webhook')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class WebhookHTTPPanel(panels.ObjectAttributesPanel):
|
||||
title = _('HTTP Request')
|
||||
|
||||
http_method = attrs.ChoiceAttr('http_method', label=_('HTTP method'))
|
||||
payload_url = attrs.TextAttr('payload_url', label=_('Payload URL'), style='font-monospace')
|
||||
http_content_type = attrs.TextAttr('http_content_type', label=_('HTTP content type'))
|
||||
secret = attrs.TextAttr('secret')
|
||||
|
||||
|
||||
class WebhookSSLPanel(panels.ObjectAttributesPanel):
|
||||
title = _('SSL')
|
||||
|
||||
ssl_verification = attrs.BooleanAttr('ssl_verification', label=_('SSL verification'))
|
||||
ca_file_path = attrs.TextAttr('ca_file_path', label=_('CA file path'))
|
||||
|
||||
|
||||
#
|
||||
# EventRule panels
|
||||
#
|
||||
|
||||
class EventRulePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Event Rule')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class EventRuleEventTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/eventrule_event_types.html'
|
||||
title = _('Event Types')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'registry': context.get('registry'),
|
||||
}
|
||||
|
||||
|
||||
class EventRuleActionPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Action')
|
||||
|
||||
action_type = attrs.ChoiceAttr('action_type', label=_('Type'))
|
||||
action_object = attrs.RelatedObjectAttr('action_object', linkify=True, label=_('Object'))
|
||||
action_data = attrs.TemplatedAttr(
|
||||
'action_data',
|
||||
label=_('Data'),
|
||||
template_name='extras/eventrule/attrs/action_data.html',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tag panels
|
||||
#
|
||||
|
||||
class TagPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Tag')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
color = attrs.ColorAttr('color')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
tagged_items = attrs.TemplatedAttr(
|
||||
'extras_taggeditem_items',
|
||||
template_name='extras/tag/attrs/tagged_item_count.html',
|
||||
)
|
||||
|
||||
|
||||
class TagObjectTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tag_object_types.html'
|
||||
title = _('Allowed Object Types')
|
||||
|
||||
|
||||
class TagItemTypesPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/tag_item_types.html'
|
||||
title = _('Tagged Item Types')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'object_types': context.get('object_types'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConfigContextProfile panels
|
||||
#
|
||||
|
||||
class ConfigContextProfilePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Context Profile')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
#
|
||||
# ConfigContext panels
|
||||
#
|
||||
|
||||
class ConfigContextPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Context')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
weight = attrs.NumericAttr('weight')
|
||||
profile = attrs.RelatedObjectAttr('profile', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
is_active = attrs.BooleanAttr('is_active', label=_('Active'))
|
||||
|
||||
|
||||
class ConfigContextAssignmentPanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/configcontext_assignment.html'
|
||||
title = _('Assignment')
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'assigned_objects': context.get('assigned_objects'),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConfigTemplate panels
|
||||
#
|
||||
|
||||
class ConfigTemplatePanel(panels.ObjectAttributesPanel):
|
||||
title = _('Config Template')
|
||||
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
|
||||
file_name = attrs.TextAttr('file_name')
|
||||
file_extension = attrs.TextAttr('file_extension')
|
||||
as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
|
||||
data_source = attrs.RelatedObjectAttr('data_source', linkify=True)
|
||||
data_file = attrs.TemplatedAttr(
|
||||
'data_path',
|
||||
template_name='extras/configtemplate/attrs/data_file.html',
|
||||
)
|
||||
data_synced = attrs.DateTimeAttr('data_synced')
|
||||
auto_sync_enabled = attrs.BooleanAttr('auto_sync_enabled')
|
||||
|
||||
|
||||
#
|
||||
# ImageAttachment panels
|
||||
#
|
||||
|
||||
class ImageAttachmentPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Image Attachment')
|
||||
|
||||
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent object'))
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
|
||||
class ImageAttachmentFilePanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/imageattachment_file.html'
|
||||
title = _('File')
|
||||
|
||||
|
||||
class ImageAttachmentImagePanel(panels.ObjectPanel):
|
||||
template_name = 'extras/panels/imageattachment_image.html'
|
||||
title = _('Image')
|
||||
|
||||
|
||||
#
|
||||
# JournalEntry panels
|
||||
#
|
||||
|
||||
class JournalEntryPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Journal Entry')
|
||||
|
||||
assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Object'))
|
||||
created = attrs.DateTimeAttr('created', spec='minutes')
|
||||
created_by = attrs.TextAttr('created_by')
|
||||
kind = attrs.ChoiceAttr('kind')
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
@@ -23,6 +23,14 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from extras.utils import SharedObjectViewMixin
|
||||
from netbox.object_actions import *
|
||||
from netbox.ui import layout
|
||||
from netbox.ui.panels import (
|
||||
CommentsPanel,
|
||||
ContextTablePanel,
|
||||
JSONPanel,
|
||||
TemplatePanel,
|
||||
TextCodePanel,
|
||||
)
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@@ -40,6 +48,7 @@ from . import filtersets, forms, tables
|
||||
from .constants import LOG_LEVEL_RANK
|
||||
from .models import *
|
||||
from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
|
||||
from .ui import panels
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
@@ -57,6 +66,18 @@ class CustomFieldListView(generic.ObjectListView):
|
||||
@register_model_view(CustomField)
|
||||
class CustomFieldView(generic.ObjectView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomFieldPanel(),
|
||||
panels.CustomFieldBehaviorPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.CustomFieldObjectTypesPanel(),
|
||||
panels.CustomFieldValidationPanel(),
|
||||
panels.CustomFieldRelatedObjectsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = ()
|
||||
@@ -128,6 +149,14 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
|
||||
@register_model_view(CustomFieldChoiceSet)
|
||||
class CustomFieldChoiceSetView(generic.ObjectView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomFieldChoiceSetPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.CustomFieldChoiceSetChoicesPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
@@ -203,6 +232,16 @@ class CustomLinkListView(generic.ObjectListView):
|
||||
@register_model_view(CustomLink)
|
||||
class CustomLinkView(generic.ObjectView):
|
||||
queryset = CustomLink.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.CustomLinkPanel(),
|
||||
panels.ObjectTypesPanel(title=_('Assigned Models')),
|
||||
],
|
||||
right_panels=[
|
||||
TextCodePanel('link_text', title=_('Link Text')),
|
||||
TextCodePanel('link_url', title=_('Link URL')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(CustomLink, 'add', detail=False)
|
||||
@@ -260,6 +299,19 @@ class ExportTemplateListView(generic.ObjectListView):
|
||||
@register_model_view(ExportTemplate)
|
||||
class ExportTemplateView(generic.ObjectView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ExportTemplatePanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ObjectTypesPanel(title=_('Assigned Models')),
|
||||
JSONPanel('environment_params', title=_('Environment Parameters')),
|
||||
],
|
||||
bottom_panels=[
|
||||
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate, 'add', detail=False)
|
||||
@@ -321,6 +373,15 @@ class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
@register_model_view(SavedFilter)
|
||||
class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.SavedFilterPanel(),
|
||||
panels.SavedFilterObjectTypesPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('parameters', title=_('Parameters')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(SavedFilter, 'add', detail=False)
|
||||
@@ -383,6 +444,15 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
@register_model_view(TableConfig)
|
||||
class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
|
||||
queryset = TableConfig.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.TableConfigPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.TableConfigColumnsPanel(),
|
||||
panels.TableConfigOrderingPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
table = instance.table_class([])
|
||||
@@ -476,6 +546,15 @@ class NotificationGroupListView(generic.ObjectListView):
|
||||
@register_model_view(NotificationGroup)
|
||||
class NotificationGroupView(generic.ObjectView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.NotificationGroupPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.NotificationGroupGroupsPanel(),
|
||||
panels.NotificationGroupUsersPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'add', detail=False)
|
||||
@@ -660,6 +739,19 @@ class WebhookListView(generic.ObjectListView):
|
||||
@register_model_view(Webhook)
|
||||
class WebhookView(generic.ObjectView):
|
||||
queryset = Webhook.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.WebhookPanel(),
|
||||
panels.WebhookHTTPPanel(),
|
||||
panels.WebhookSSLPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TextCodePanel('additional_headers', title=_('Additional Headers')),
|
||||
TextCodePanel('body_template', title=_('Body Template')),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Webhook, 'add', detail=False)
|
||||
@@ -716,6 +808,19 @@ class EventRuleListView(generic.ObjectListView):
|
||||
@register_model_view(EventRule)
|
||||
class EventRuleView(generic.ObjectView):
|
||||
queryset = EventRule.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.EventRulePanel(),
|
||||
panels.ObjectTypesPanel(),
|
||||
panels.EventRuleEventTypesPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('conditions', title=_('Conditions')),
|
||||
panels.EventRuleActionPanel(),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'add', detail=False)
|
||||
@@ -774,6 +879,18 @@ class TagListView(generic.ObjectListView):
|
||||
@register_model_view(Tag)
|
||||
class TagView(generic.ObjectView):
|
||||
queryset = Tag.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.TagPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.TagObjectTypesPanel(),
|
||||
panels.TagItemTypesPanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
ContextTablePanel('taggeditem_table', title=_('Tagged Objects')),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
tagged_items = TaggedItem.objects.filter(tag=instance)
|
||||
@@ -853,6 +970,18 @@ class ConfigContextProfileListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigContextProfile)
|
||||
class ConfigContextProfileView(generic.ObjectView):
|
||||
queryset = ConfigContextProfile.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigContextProfilePanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
CommentsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('schema', title=_('JSON Schema')),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConfigContextProfile, 'add', detail=False)
|
||||
@@ -915,6 +1044,16 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigContext)
|
||||
class ConfigContextView(generic.ObjectView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigContextPanel(),
|
||||
TemplatePanel('core/inc/datafile_panel.html'),
|
||||
panels.ConfigContextAssignmentPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
TemplatePanel('extras/panels/configcontext_data.html'),
|
||||
],
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Gather assigned objects for parsing in the template
|
||||
@@ -1034,6 +1173,18 @@ class ConfigTemplateListView(generic.ObjectListView):
|
||||
@register_model_view(ConfigTemplate)
|
||||
class ConfigTemplateView(generic.ObjectView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ConfigTemplatePanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
JSONPanel('environment_params', title=_('Environment Parameters')),
|
||||
],
|
||||
bottom_panels=[
|
||||
TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'add', detail=False)
|
||||
@@ -1151,6 +1302,17 @@ class ImageAttachmentListView(generic.ObjectListView):
|
||||
@register_model_view(ImageAttachment)
|
||||
class ImageAttachmentView(generic.ObjectView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ImageAttachmentPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
panels.ImageAttachmentFilePanel(),
|
||||
],
|
||||
bottom_panels=[
|
||||
panels.ImageAttachmentImagePanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'add', detail=False)
|
||||
@@ -1215,6 +1377,16 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
@register_model_view(JournalEntry)
|
||||
class JournalEntryView(generic.ObjectView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.JournalEntryPanel(),
|
||||
panels.CustomFieldsPanel(),
|
||||
panels.TagsPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
CommentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(JournalEntry, 'add', detail=False)
|
||||
|
||||
@@ -159,9 +159,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
if self.prefix:
|
||||
return self.prefix.version
|
||||
return None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).version
|
||||
return self.prefix.version
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
@@ -335,11 +337,19 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
return self.prefix.version if self.prefix else None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).version
|
||||
return self.prefix.version
|
||||
|
||||
@property
|
||||
def mask_length(self):
|
||||
return self.prefix.prefixlen if self.prefix else None
|
||||
if not self.prefix:
|
||||
return None
|
||||
if isinstance(self.prefix, str):
|
||||
return netaddr.IPNetwork(self.prefix).prefixlen
|
||||
return self.prefix.prefixlen
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
@@ -642,7 +652,11 @@ class IPRange(ContactsMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
return self.start_address.version if self.start_address else None
|
||||
if not self.start_address:
|
||||
return None
|
||||
if isinstance(self.start_address, str):
|
||||
return netaddr.IPAddress(self.start_address.split('/')[0]).version
|
||||
return self.start_address.version
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
@@ -990,9 +1004,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
if self.address:
|
||||
return self.address.version
|
||||
return None
|
||||
if not self.address:
|
||||
return None
|
||||
if isinstance(self.address, str):
|
||||
return netaddr.IPNetwork(self.address).version
|
||||
return self.address.version
|
||||
|
||||
@property
|
||||
def is_oob_ip(self):
|
||||
|
||||
@@ -247,6 +247,6 @@ class VLANTranslationRuleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VLANTranslationRule
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'policy', 'local_vid', 'remote_vid', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'policy', 'local_vid', 'remote_vid', 'description')
|
||||
|
||||
@@ -11,6 +11,13 @@ from utilities.data import string_to_ranges
|
||||
|
||||
class TestAggregate(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when prefix is a string
|
||||
agg = Aggregate(prefix='10.0.0.0/8')
|
||||
self.assertEqual(agg.family, 4)
|
||||
agg_v6 = Aggregate(prefix='2001:db8::/32')
|
||||
self.assertEqual(agg_v6.family, 6)
|
||||
|
||||
def test_get_utilization(self):
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
|
||||
@@ -40,6 +47,13 @@ class TestAggregate(TestCase):
|
||||
|
||||
class TestIPRange(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when start_address is a string
|
||||
ip_range = IPRange(start_address='10.0.0.1/24', end_address='10.0.0.254/24')
|
||||
self.assertEqual(ip_range.family, 4)
|
||||
ip_range_v6 = IPRange(start_address='2001:db8::1/64', end_address='2001:db8::ffff/64')
|
||||
self.assertEqual(ip_range_v6.family, 6)
|
||||
|
||||
def test_overlapping_range(self):
|
||||
iprange_192_168 = IPRange.objects.create(
|
||||
start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')
|
||||
@@ -90,6 +104,20 @@ class TestIPRange(TestCase):
|
||||
|
||||
class TestPrefix(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when prefix is a string
|
||||
prefix = Prefix(prefix='10.0.0.0/8')
|
||||
self.assertEqual(prefix.family, 4)
|
||||
prefix_v6 = Prefix(prefix='2001:db8::/32')
|
||||
self.assertEqual(prefix_v6.family, 6)
|
||||
|
||||
def test_mask_length_string(self):
|
||||
# Test property when prefix is a string
|
||||
prefix = Prefix(prefix='10.0.0.0/8')
|
||||
self.assertEqual(prefix.mask_length, 8)
|
||||
prefix_v6 = Prefix(prefix='2001:db8::/32')
|
||||
self.assertEqual(prefix_v6.mask_length, 32)
|
||||
|
||||
def test_get_duplicates(self):
|
||||
prefixes = Prefix.objects.bulk_create((
|
||||
Prefix(prefix=IPNetwork('192.0.2.0/24')),
|
||||
@@ -533,6 +561,13 @@ class TestPrefixHierarchy(TestCase):
|
||||
|
||||
class TestIPAddress(TestCase):
|
||||
|
||||
def test_family_string(self):
|
||||
# Test property when address is a string
|
||||
ip = IPAddress(address='10.0.0.1/24')
|
||||
self.assertEqual(ip.family, 4)
|
||||
ip_v6 = IPAddress(address='2001:db8::1/64')
|
||||
self.assertEqual(ip_v6.family, 6)
|
||||
|
||||
def test_get_duplicates(self):
|
||||
ips = IPAddress.objects.bulk_create((
|
||||
IPAddress(address=IPNetwork('192.0.2.1/24')),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from ipam.models import IPAddress, IPRange, Prefix
|
||||
from ipam.tables import AnnotatedIPAddressTable
|
||||
from ipam.models import FHRPGroupAssignment, IPAddress, IPRange, Prefix
|
||||
from ipam.tables import *
|
||||
from ipam.utils import annotate_ip_space
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
class AnnotatedIPAddressTableTest(TestCase):
|
||||
@@ -168,3 +169,82 @@ class AnnotatedIPAddressTableTest(TestCase):
|
||||
# Pools are fully usable
|
||||
self.assertEqual(available.first_ip, '2001:db8:1::/126')
|
||||
self.assertEqual(available.size, 4)
|
||||
|
||||
|
||||
#
|
||||
# Table ordering tests
|
||||
#
|
||||
|
||||
class VRFTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VRFTable
|
||||
|
||||
|
||||
class RouteTargetTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RouteTargetTable
|
||||
|
||||
|
||||
class RIRTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RIRTable
|
||||
|
||||
|
||||
class AggregateTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = AggregateTable
|
||||
|
||||
|
||||
class RoleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = RoleTable
|
||||
|
||||
|
||||
class PrefixTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = PrefixTable
|
||||
|
||||
|
||||
class IPRangeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = IPRangeTable
|
||||
|
||||
|
||||
class IPAddressTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = IPAddressTable
|
||||
|
||||
|
||||
class FHRPGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = FHRPGroupTable
|
||||
|
||||
|
||||
class FHRPGroupAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = FHRPGroupAssignmentTable
|
||||
queryset_sources = [
|
||||
('FHRPGroupAssignmentTable', FHRPGroupAssignment.objects.all()),
|
||||
]
|
||||
|
||||
|
||||
class VLANGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VLANGroupTable
|
||||
|
||||
|
||||
class VLANTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VLANTable
|
||||
|
||||
|
||||
class VLANTranslationPolicyTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VLANTranslationPolicyTable
|
||||
|
||||
|
||||
class VLANTranslationRuleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = VLANTranslationRuleTable
|
||||
|
||||
|
||||
class ASNRangeTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ASNRangeTable
|
||||
|
||||
|
||||
class ASNTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ASNTable
|
||||
|
||||
|
||||
class ServiceTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ServiceTemplateTable
|
||||
|
||||
|
||||
class ServiceTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ServiceTable
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import CreateOnlyDefault
|
||||
|
||||
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldsDataField
|
||||
from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
|
||||
|
||||
from .base import ValidatedModelSerializer
|
||||
from .nested import NestedTagSerializer
|
||||
@@ -23,6 +23,29 @@ class CustomFieldModelSerializer(serializers.Serializer):
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def many_init(cls, *args, **kwargs):
|
||||
"""
|
||||
We can't call super().many_init() and change the outcome because by the time it returns,
|
||||
the plain ListSerializer is already instantiated.
|
||||
Because every NetBox serializer defines its own Meta which doesn't inherit from a parent Meta,
|
||||
this would silently not apply to any real serializer.
|
||||
Thats why this method replicates many_init from parent and changed the default value for list_serializer_class.
|
||||
"""
|
||||
list_kwargs = {}
|
||||
for key in serializers.LIST_SERIALIZER_KWARGS_REMOVE:
|
||||
value = kwargs.pop(key, None)
|
||||
if value is not None:
|
||||
list_kwargs[key] = value
|
||||
list_kwargs['child'] = cls(*args, **kwargs)
|
||||
list_kwargs.update({
|
||||
key: value for key, value in kwargs.items()
|
||||
if key in serializers.LIST_SERIALIZER_KWARGS
|
||||
})
|
||||
meta = getattr(cls, 'Meta', None)
|
||||
list_serializer_class = getattr(meta, 'list_serializer_class', CustomFieldListSerializer)
|
||||
return list_serializer_class(*args, **list_kwargs)
|
||||
|
||||
|
||||
class TaggableModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,10 @@ PLUGINS = [
|
||||
'netbox.tests.dummy_plugin',
|
||||
]
|
||||
|
||||
RQ = {
|
||||
'COMMIT_MODE': 'auto',
|
||||
}
|
||||
|
||||
REDIS = {
|
||||
'tasks': {
|
||||
'HOST': 'localhost',
|
||||
|
||||
@@ -168,6 +168,7 @@ REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAM
|
||||
REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
|
||||
# Required by extras/migrations/0109_script_models.py
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ = getattr(configuration, 'RQ', {})
|
||||
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)
|
||||
|
||||
@@ -159,7 +159,7 @@ class BaseTable(tables.Table):
|
||||
columns = None
|
||||
ordering = None
|
||||
|
||||
if self.prefixed_order_by_field in request.GET:
|
||||
if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
|
||||
if request.GET[self.prefixed_order_by_field]:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.template import Context, Template
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
@@ -46,6 +47,16 @@ class BaseTableTest(TestCase):
|
||||
prefetch_lookups = table.data.data._prefetch_related_lookups
|
||||
self.assertEqual(prefetch_lookups, tuple())
|
||||
|
||||
def test_configure_anonymous_user_with_ordering(self):
|
||||
"""
|
||||
Verify that table.configure() does not raise an error when an anonymous
|
||||
user sorts a table column.
|
||||
"""
|
||||
request = RequestFactory().get('/?sort=name')
|
||||
request.user = AnonymousUser()
|
||||
table = DeviceTable(Device.objects.all())
|
||||
table.configure(request)
|
||||
|
||||
|
||||
class TagColumnTable(NetBoxTable):
|
||||
tags = columns.TagColumn(url_name='dcim:site_list')
|
||||
|
||||
@@ -23,6 +23,7 @@ __all__ = (
|
||||
'PluginContentPanel',
|
||||
'RelatedObjectsPanel',
|
||||
'TemplatePanel',
|
||||
'TextCodePanel',
|
||||
)
|
||||
|
||||
|
||||
@@ -329,6 +330,25 @@ class TemplatePanel(Panel):
|
||||
return render_to_string(self.template_name, context.flatten())
|
||||
|
||||
|
||||
class TextCodePanel(ObjectPanel):
|
||||
"""
|
||||
A panel displaying a text field as a pre-formatted code block.
|
||||
"""
|
||||
template_name = 'ui/panels/text_code.html'
|
||||
|
||||
def __init__(self, field_name, show_sync_warning=False, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.field_name = field_name
|
||||
self.show_sync_warning = show_sync_warning
|
||||
|
||||
def get_context(self, context):
|
||||
return {
|
||||
**super().get_context(context),
|
||||
'show_sync_warning': self.show_sync_warning,
|
||||
'value': getattr(context.get('object'), self.field_name, None),
|
||||
}
|
||||
|
||||
|
||||
class PluginContentPanel(Panel):
|
||||
"""
|
||||
A panel which displays embedded plugin content.
|
||||
|
||||
@@ -1,62 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Context" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Profile" %}</th>
|
||||
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Active" %}</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'core/inc/datafile_panel.html' %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assignment" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for title, objects in assigned_objects %}
|
||||
<tr>
|
||||
<th scope="row">{{ title }}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for object in objects %}
|
||||
<li>{{ object|linkify }}</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">{% trans "None" %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-12 col-md-7">
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
<div class="card">
|
||||
{% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,39 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Context Profile" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'core/inc/datafile_panel.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header d-flex justify-content-between">
|
||||
{% trans "JSON Schema" %}
|
||||
<div>
|
||||
{% copy_content "schema" %}
|
||||
</div>
|
||||
</h2>
|
||||
<pre class="card-body rendered-context-data m-0" id="schema">{{ object.schema|json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Template" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "MIME Type" %}</th>
|
||||
<td>{{ object.mime_type|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Name" %}</th>
|
||||
<td>{{ object.file_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Extension" %}</th>
|
||||
<td>{{ object.file_extension|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Attachment" %}</th>
|
||||
<td>{% checkmark object.as_attachment %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Source" %}</th>
|
||||
<td>
|
||||
{% if object.data_source %}
|
||||
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data File" %}</th>
|
||||
<td>
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% elif object.data_path %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
|
||||
</div>
|
||||
{{ object.data_path }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Synced" %}</th>
|
||||
<td>{{ object.data_synced|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Auto Sync Enabled" %}</th>
|
||||
<td>{% checkmark object.auto_sync_enabled %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.environment_params %}
|
||||
<pre>{{ object.environment_params|json }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Template" %}</h2>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
<pre>{{ object.template_code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{% load i18n %}
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% else %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="{% trans "The data file associated with this object has been deleted" %}."></i>
|
||||
</div>
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
@@ -1,163 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Custom Field" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Type</th>
|
||||
<td>
|
||||
{{ object.get_type_display }}
|
||||
{% if object.related_object_type %}
|
||||
({{ object.related_object_type.model|bettertitle }})
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Label" %}</th>
|
||||
<td>{{ object.label|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group Name" %}</th>
|
||||
<td>{{ object.group_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Required" %}</th>
|
||||
<td>{% checkmark object.required %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Must be Unique" %}</th>
|
||||
<td>{% checkmark object.unique %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Cloneable" %}</th>
|
||||
<td>{% checkmark object.is_cloneable %}</td>
|
||||
</tr>
|
||||
{% if object.choice_set %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Choice Set" %}</th>
|
||||
<td>{{ object.choice_set|linkify }} ({{ object.choice_set.choices|length }} choices)</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Default Value" %}</th>
|
||||
<td>{{ object.default }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Related object filter" %}</th>
|
||||
{% if object.related_object_filter %}
|
||||
<td><pre>{{ object.related_object_filter|json }}</pre></td>
|
||||
{% else %}
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Behavior" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Search Weight" %}</th>
|
||||
<td>
|
||||
{% if object.search_weight %}
|
||||
{{ object.search_weight }}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "Disabled" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Filter Logic" %}</th>
|
||||
<td>{{ object.get_filter_logic_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Display Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "UI Visible" %}</th>
|
||||
<td>{{ object.get_ui_visible_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "UI Editable" %}</th>
|
||||
<td>{{ object.get_ui_editable_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Object Types" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Validation Rules" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Minimum Value" %}</th>
|
||||
<td>{{ object.validation_minimum|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Maximum Value" %}</th>
|
||||
<td>{{ object.validation_maximum|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Regular Expression" %}</th>
|
||||
<td>
|
||||
{% if object.validation_regex %}
|
||||
<code>{{ object.validation_regex }}</code>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">Related Objects</h2>
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for qs in related_models %}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
|
||||
{% with count=qs.count %}
|
||||
{% if count %}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ count }}</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-light rounded-pill">—</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% load helpers i18n %}{{ value|linkify }} ({{ value.choices|length }} {% trans "choices" %})
|
||||
@@ -0,0 +1 @@
|
||||
{% load helpers %}<pre>{{ value|json }}</pre>
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% if value %}{{ value }}{% else %}<span class="text-muted">{% trans "Disabled" %}</span>{% endif %}
|
||||
1
netbox/templates/extras/customfield/attrs/type.html
Normal file
1
netbox/templates/extras/customfield/attrs/type.html
Normal file
@@ -0,0 +1 @@
|
||||
{% load helpers %}{{ object.get_type_display }}{% if object.related_object_type %} ({{ object.related_object_type.model|bettertitle }}){% endif %}
|
||||
@@ -1,72 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">Custom Field Choice Set</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Base Choices</th>
|
||||
<td>{{ object.get_base_choices_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Choices</th>
|
||||
<td>{{ object.choices|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Order Alphabetically</th>
|
||||
<td>{% checkmark object.order_alphabetically %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Used by</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for cf in object.choices_for.all %}
|
||||
<li>{{ cf|linkify }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">Choices ({{ object.choices|length }})</h2>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Value</th>
|
||||
<th>Label</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for value, label in choices %}
|
||||
<tr>
|
||||
<td>{{ value }}</td>
|
||||
<td>{{ label }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% include 'inc/paginator.html' with page=choices %}
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Custom Link" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group Name" %}</th>
|
||||
<td>{{ object.group_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Button Class" %}</th>
|
||||
<td>{{ object.get_button_class_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "New Window" %}</th>
|
||||
<td>{% checkmark object.new_window %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-7">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Link Text" %}</h2>
|
||||
<div class="card-body">
|
||||
<pre>{{ object.link_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Link URL" %}</h2>
|
||||
<div class="card-body">
|
||||
<pre>{{ object.link_url }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,105 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Event Rule" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Object Types" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for object_type in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ object_type }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Event Types" %}</h2>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for name, event in registry.event_types.items %}
|
||||
<li class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
{% if name in object.event_types %}
|
||||
{% checkmark True %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ event }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Conditions" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.conditions %}
|
||||
<pre>{{ object.conditions|json }}</pre>
|
||||
{% else %}
|
||||
<p class="text-muted">{% trans "None" %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Action" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Type" %}</th>
|
||||
<td>{{ object.get_action_type_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Object" %}</th>
|
||||
<td>
|
||||
{{ object.action_object|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data" %}</th>
|
||||
<td>
|
||||
{% if object.action_data %}
|
||||
<pre>{{ object.action_data|json }}</pre>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
1
netbox/templates/extras/eventrule/attrs/action_data.html
Normal file
1
netbox/templates/extras/eventrule/attrs/action_data.html
Normal file
@@ -0,0 +1 @@
|
||||
{% load helpers %}{% if value %}<pre>{{ value|json }}</pre>{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
@@ -1,79 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Export Template" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "MIME Type" %}</th>
|
||||
<td>{{ object.mime_type|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Name" %}</th>
|
||||
<td>{{ object.file_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Extension" %}</th>
|
||||
<td>{{ object.file_extension|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Attachment" %}</th>
|
||||
<td>{% checkmark object.as_attachment %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'core/inc/datafile_panel.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for object_type in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ object_type }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Environment Parameters" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.environment_params %}
|
||||
<pre>{{ object.environment_params|json }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Template" %}</h2>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
<pre>{{ object.template_code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,67 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Image Attachment" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Parent Object" %}</th>
|
||||
<td>{{ object.parent|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "File" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Filename" %}</th>
|
||||
<td>
|
||||
<a href="{{ object.image.url }}" target="_blank">{{ object.filename }}</a>
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Dimensions" %}</th>
|
||||
<td>{{ object.image_width }} × {{ object.image_height }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Size" %}</th>
|
||||
<td>
|
||||
<span title="{{ object.size }} {% trans "bytes" %}">{{ object.size|filesizeformat }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Image" %}</h2>
|
||||
<div class="card-body">
|
||||
<a href="{{ object.image.url }}" title="{{ object.name }}">
|
||||
{{ object.html_tag }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,42 +1,7 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
<li class="breadcrumb-item"><a href="{% action_url object.assigned_object 'journal' pk=object.assigned_object.pk %}">{{ object.assigned_object }}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Journal Entry" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Object" %}</th>
|
||||
<td>{{ object.assigned_object|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Created" %}</th>
|
||||
<td>{{ object.created|isodatetime:"minutes" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Created By" %}</th>
|
||||
<td>{{ object.created_by }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Kind" %}</th>
|
||||
<td>{% badge object.get_kind_display bg_color=object.get_kind_color %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
</div>
|
||||
<div class="col col-md-7">
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Notification Group" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>
|
||||
{{ object.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>
|
||||
{{ object.description|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Groups" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.groups.all %}
|
||||
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Users" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
21
netbox/templates/extras/panels/configcontext_assignment.html
Normal file
21
netbox/templates/extras/panels/configcontext_assignment.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for title, objects in assigned_objects %}
|
||||
<tr>
|
||||
<th scope="row">{{ title }}</th>
|
||||
<td>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for obj in objects %}
|
||||
<li>{{ obj|linkify }}</li>
|
||||
{% empty %}
|
||||
<li class="text-muted">{% trans "None" %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
5
netbox/templates/extras/panels/configcontext_data.html
Normal file
5
netbox/templates/extras/panels/configcontext_data.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% load helpers i18n %}
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
<div class="card">
|
||||
{% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block panel_content %}
|
||||
<pre class="card-body rendered-context-data m-0" id="schema">{{ object.schema|json }}</pre>
|
||||
{% endblock panel_content %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for qs in related_models %}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
|
||||
{% with count=qs.count %}
|
||||
{% if count %}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ count }}</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-light rounded-pill">—</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</a>
|
||||
{% empty %}
|
||||
<span class="list-group-item text-muted">{% trans "None" %}</span>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock panel_content %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Value" %}</th>
|
||||
<th>{% trans "Label" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value, label in choices %}
|
||||
<tr>
|
||||
<td>{{ value }}</td>
|
||||
<td>{{ label }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% include 'inc/paginator.html' with page=choices %}
|
||||
{% endblock panel_content %}
|
||||
23
netbox/templates/extras/panels/eventrule_event_types.html
Normal file
23
netbox/templates/extras/panels/eventrule_event_types.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block panel_content %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for name, event in registry.event_types.items %}
|
||||
<li class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
{% if name in object.event_types %}
|
||||
{% checkmark True %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ event }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock panel_content %}
|
||||
24
netbox/templates/extras/panels/imageattachment_file.html
Normal file
24
netbox/templates/extras/panels/imageattachment_file.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Filename" %}</th>
|
||||
<td>
|
||||
<a href="{{ object.image.url }}" target="_blank">{{ object.filename }}</a>
|
||||
<i class="mdi mdi-open-in-new"></i>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Dimensions" %}</th>
|
||||
<td>{{ object.image_width }} × {{ object.image_height }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Size" %}</th>
|
||||
<td>
|
||||
<span title="{{ object.size }} {% trans "bytes" %}">{{ object.size|filesizeformat }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class="card-body">
|
||||
<a href="{{ object.image.url }}" title="{{ object.name }}">
|
||||
{{ object.html_tag }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock panel_content %}
|
||||
12
netbox/templates/extras/panels/notificationgroup_groups.html
Normal file
12
netbox/templates/extras/panels/notificationgroup_groups.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.groups.all %}
|
||||
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock panel_content %}
|
||||
12
netbox/templates/extras/panels/notificationgroup_users.html
Normal file
12
netbox/templates/extras/panels/notificationgroup_users.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None assigned" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock panel_content %}
|
||||
11
netbox/templates/extras/panels/object_types.html
Normal file
11
netbox/templates/extras/panels/object_types.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
16
netbox/templates/extras/panels/savedfilter_object_types.html
Normal file
16
netbox/templates/extras/panels/savedfilter_object_types.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class="list-group list-group-flush" role="presentation">
|
||||
{% for object_type in object.object_types.all %}
|
||||
{% with object_type.model_class|validated_viewname:"list" as viewname %}
|
||||
{% if viewname %}
|
||||
<a href="{% url viewname %}?{{ object.url_params }}" class="list-group-item list-group-item-action">{{ object_type }}</a>
|
||||
{% else %}
|
||||
<div class="list-group-item list-group-item-action">{{ object_type }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock panel_content %}
|
||||
15
netbox/templates/extras/panels/tableconfig_columns.html
Normal file
15
netbox/templates/extras/panels/tableconfig_columns.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for name in object.columns %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
{% with column=columns|get_key:name %}
|
||||
{{ column.verbose_name }}
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock panel_content %}
|
||||
22
netbox/templates/extras/panels/tableconfig_ordering.html
Normal file
22
netbox/templates/extras/panels/tableconfig_ordering.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for column_name, ascending in object.ordering_items %}
|
||||
<li class="list-group-item">
|
||||
{% with column=columns|get_key:column_name %}
|
||||
{% if ascending %}
|
||||
<i class="mdi mdi-arrow-down-thick"></i>
|
||||
{% else %}
|
||||
<i class="mdi mdi-arrow-up-thick"></i>
|
||||
{% endif %}
|
||||
{{ column.verbose_name }}
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">{% trans "Default" %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock panel_content %}
|
||||
21
netbox/templates/extras/panels/tag_item_types.html
Normal file
21
netbox/templates/extras/panels/tag_item_types.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load helpers i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for object_type in object_types %}
|
||||
{% action_url object_type.content_type.model_class 'list' as list_url %}
|
||||
{% if list_url %}
|
||||
<a href="{{ list_url }}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<li class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock panel_content %}
|
||||
16
netbox/templates/extras/panels/tag_object_types.html
Normal file
16
netbox/templates/extras/panels/tag_object_types.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-muted">{% trans "Any" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock panel_content %}
|
||||
@@ -1,69 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Saved Filter" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "User" %}</th>
|
||||
<td>{{ object.user|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Shared" %}</th>
|
||||
<td>{% checkmark object.shared %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
|
||||
<div class="list-group list-group-flush" role="presentation">
|
||||
{% for object_type in object.object_types.all %}
|
||||
{% with object_type.model_class|validated_viewname:"list" as viewname %}
|
||||
{% if viewname %}
|
||||
<a href="{% url viewname %}?{{ object.url_params }}" class="list-group-item list-group-item-action">{{ object_type }}</a>
|
||||
{% else %}
|
||||
<div class="list-group-item list-group-item-action">{{ object_type }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-7">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Parameters" %}</h2>
|
||||
<div class="card-body p-0">
|
||||
<pre>{{ object.parameters|json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,88 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Table Config" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Object Type" %}</th>
|
||||
<td>{{ object.object_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Table" %}</th>
|
||||
<td>{{ object.table }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "User" %}</th>
|
||||
<td>{{ object.user|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Shared" %}</th>
|
||||
<td>{% checkmark object.shared %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Columns Displayed" %}</h2>
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for name in object.columns %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
{% with column=columns|get_key:name %}
|
||||
{{ column.verbose_name }}
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Ordering" %}</h2>
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for column, ascending in object.ordering_items %}
|
||||
<li class="list-group-item">
|
||||
{% with column=columns|get_key:column %}
|
||||
{% if ascending %}
|
||||
<i class="mdi mdi-arrow-down-thick"></i>
|
||||
{% else %}
|
||||
<i class="mdi mdi-arrow-up-thick"></i>
|
||||
{% endif %}
|
||||
{{ column.verbose_name }}
|
||||
{% endwith %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">{% trans "Default" %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,94 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Tag" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>
|
||||
{{ object.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>
|
||||
{{ object.description|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Color" %}</th>
|
||||
<td>
|
||||
<span class="color-label" style="background-color: #{{ object.color }}"> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tagged Items" %}</th>
|
||||
<td>
|
||||
{{ taggeditem_table.rows|length }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Allowed Object Types" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
{% for ct in object.object_types.all %}
|
||||
<tr>
|
||||
<td>{{ ct }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="text-muted">{% trans "Any" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Tagged Item Types" %}</h2>
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for object_type in object_types %}
|
||||
{% action_url object_type.content_type.model_class 'list' as list_url %}
|
||||
{% if list_url %}
|
||||
<a href="{{ list_url }}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<li class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Tagged Objects" %}</h2>
|
||||
<div class="table-responsive">
|
||||
{% render_table taggeditem_table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
1
netbox/templates/extras/tag/attrs/tagged_item_count.html
Normal file
1
netbox/templates/extras/tag/attrs/tagged_item_count.html
Normal file
@@ -0,0 +1 @@
|
||||
{{ value.count }}
|
||||
@@ -1,89 +1 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Webhook" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "HTTP Request" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "HTTP Method" %}</th>
|
||||
<td>{{ object.get_http_method_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Payload URL" %}</th>
|
||||
<td class="font-monospace">{{ object.payload_url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "HTTP Content Type" %}</th>
|
||||
<td>{{ object.http_content_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Secret" %}</th>
|
||||
<td>{{ object.secret|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "SSL" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "SSL Verification" %}</th>
|
||||
<td>{% checkmark object.ssl_verification %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "CA File Path" %}</th>
|
||||
<td>{{ object.ca_file_path|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Additional Headers" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.additional_headers %}
|
||||
<pre>{{ object.additional_headers }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Body Template" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.body_template %}
|
||||
<pre>{{ object.body_template }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
15
netbox/templates/ui/panels/text_code.html
Normal file
15
netbox/templates/ui/panels/text_code.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "ui/panels/_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class="card-body">
|
||||
{% if value %}
|
||||
<pre>{{ value }}</pre>
|
||||
{% else %}
|
||||
{% if show_sync_warning %}
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
{% endif %}
|
||||
<span class="text-muted">{% trans "None" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock panel_content %}
|
||||
@@ -12,7 +12,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if memory_sum %}
|
||||
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
|
||||
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_capacity }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
@@ -24,7 +24,7 @@
|
||||
</th>
|
||||
<td>
|
||||
{% if disk_sum %}
|
||||
{{ disk_sum|humanize_disk_megabytes }}
|
||||
{{ disk_sum|humanize_disk_capacity }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_ram_capacity }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
@@ -24,7 +24,7 @@
|
||||
</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk|humanize_disk_megabytes }}
|
||||
{{ object.disk|humanize_disk_capacity }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
{% load helpers %}
|
||||
{{ value|humanize_disk_megabytes }}
|
||||
{{ value|humanize_disk_capacity }}
|
||||
|
||||
26
netbox/tenancy/tests/test_tables.py
Normal file
26
netbox/tenancy/tests/test_tables.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from tenancy.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
class TenantGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = TenantGroupTable
|
||||
|
||||
|
||||
class TenantTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = TenantTable
|
||||
|
||||
|
||||
class ContactGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ContactGroupTable
|
||||
|
||||
|
||||
class ContactRoleTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ContactRoleTable
|
||||
|
||||
|
||||
class ContactTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ContactTable
|
||||
|
||||
|
||||
class ContactAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ContactAssignmentTable
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,26 @@
|
||||
from django.test import RequestFactory, TestCase, tag
|
||||
|
||||
from users.models import Token
|
||||
from users.tables import TokenTable
|
||||
from users.tables import *
|
||||
from utilities.testing import TableTestCases
|
||||
|
||||
|
||||
class TokenTableTest(TestCase):
|
||||
@tag('regression')
|
||||
def test_every_orderable_field_does_not_throw_exception(self):
|
||||
tokens = Token.objects.all()
|
||||
disallowed = {'actions'}
|
||||
class TokenTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = TokenTable
|
||||
|
||||
orderable_columns = [
|
||||
column.name for column in TokenTable(tokens).columns
|
||||
if column.orderable and column.name not in disallowed
|
||||
]
|
||||
fake_request = RequestFactory().get("/")
|
||||
|
||||
for col in orderable_columns:
|
||||
for direction in ('-', ''):
|
||||
with self.subTest(col=col, direction=direction):
|
||||
table = TokenTable(tokens)
|
||||
table.order_by = f'{direction}{col}'
|
||||
table.as_html(fake_request)
|
||||
class UserTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = UserTable
|
||||
|
||||
|
||||
class GroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = GroupTable
|
||||
|
||||
|
||||
class ObjectPermissionTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = ObjectPermissionTable
|
||||
|
||||
|
||||
class OwnerGroupTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = OwnerGroupTable
|
||||
|
||||
|
||||
class OwnerTableTest(TableTestCases.OrderableColumnsTestCase):
|
||||
table = OwnerTable
|
||||
|
||||
@@ -14,6 +14,7 @@ __all__ = (
|
||||
'expand_alphanumeric_pattern',
|
||||
'expand_ipaddress_pattern',
|
||||
'form_from_model',
|
||||
'get_capacity_unit_label',
|
||||
'get_field_value',
|
||||
'get_selected_values',
|
||||
'parse_alphanumeric_range',
|
||||
@@ -130,6 +131,13 @@ def expand_ipaddress_pattern(string, family):
|
||||
yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant])
|
||||
|
||||
|
||||
def get_capacity_unit_label(divisor=1000):
|
||||
"""
|
||||
Return the appropriate base unit label: 'MiB' for binary (1024), 'MB' for decimal (1000).
|
||||
"""
|
||||
return 'MiB' if divisor == 1024 else 'MB'
|
||||
|
||||
|
||||
def get_field_value(form, field_name):
|
||||
"""
|
||||
Return the current bound or initial value associated with a form field, prior to calling
|
||||
|
||||
@@ -48,11 +48,13 @@ class FilterModifierWidget(forms.Widget):
|
||||
Just the value string for form validation. The modifier is reconstructed
|
||||
during rendering from the query parameter names.
|
||||
"""
|
||||
# Special handling for empty - check if field__empty exists
|
||||
# Special handling for empty modifier: return None so the underlying field does not
|
||||
# attempt to validate 'true'/'false' as a field value (e.g. a model PK). The
|
||||
# `__empty` query parameter is consumed directly by the filterset and by
|
||||
# `applied_filters`, so no value from the field itself is needed here.
|
||||
empty_param = f"{name}__empty"
|
||||
if empty_param in data:
|
||||
# Return the boolean value for empty lookup
|
||||
return data.get(empty_param)
|
||||
return None
|
||||
|
||||
# Try exact field name first
|
||||
value = self.original_widget.value_from_datadict(data, files, name)
|
||||
@@ -113,8 +115,13 @@ class FilterModifierWidget(forms.Widget):
|
||||
# Build a minimal choice list with just the selected values
|
||||
choices = []
|
||||
if pk_values:
|
||||
selected_objects = original_choices.queryset.filter(pk__in=pk_values)
|
||||
choices = [(obj.pk, str(obj)) for obj in selected_objects]
|
||||
try:
|
||||
selected_objects = original_choices.queryset.filter(pk__in=pk_values)
|
||||
choices = [(obj.pk, str(obj)) for obj in selected_objects]
|
||||
except (ValueError, TypeError):
|
||||
# pk_values may contain non-PK strings (e.g. 'true'/'false' from the
|
||||
# empty modifier); silently skip rendering selected choices in that case.
|
||||
pass
|
||||
|
||||
# Re-add the "None" option if it was selected via the null choice value
|
||||
if settings.FILTERS_NULL_CHOICE_VALUE in values:
|
||||
|
||||
@@ -20,8 +20,8 @@ __all__ = (
|
||||
'divide',
|
||||
'get_item',
|
||||
'get_key',
|
||||
'humanize_disk_megabytes',
|
||||
'humanize_ram_megabytes',
|
||||
'humanize_disk_capacity',
|
||||
'humanize_ram_capacity',
|
||||
'humanize_speed',
|
||||
'icon_from_status',
|
||||
'kg_to_pounds',
|
||||
@@ -208,42 +208,52 @@ def humanize_speed(speed):
|
||||
return '{} Kbps'.format(speed)
|
||||
|
||||
|
||||
def _humanize_megabytes(mb, divisor=1000):
|
||||
def _humanize_capacity(value, divisor=1000):
|
||||
"""
|
||||
Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.).
|
||||
Express a capacity value in the most suitable unit (e.g. GB, TiB, etc.).
|
||||
|
||||
The value is treated as a unitless base-unit quantity; the divisor determines
|
||||
both the scaling thresholds and the label convention:
|
||||
- 1000: SI labels (MB, GB, TB, PB)
|
||||
- 1024: IEC labels (MiB, GiB, TiB, PiB)
|
||||
"""
|
||||
if not mb:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if divisor == 1024:
|
||||
labels = ('MiB', 'GiB', 'TiB', 'PiB')
|
||||
else:
|
||||
labels = ('MB', 'GB', 'TB', 'PB')
|
||||
|
||||
PB_SIZE = divisor**3
|
||||
TB_SIZE = divisor**2
|
||||
GB_SIZE = divisor
|
||||
|
||||
if mb >= PB_SIZE:
|
||||
return f"{mb / PB_SIZE:.2f} PB"
|
||||
if mb >= TB_SIZE:
|
||||
return f"{mb / TB_SIZE:.2f} TB"
|
||||
if mb >= GB_SIZE:
|
||||
return f"{mb / GB_SIZE:.2f} GB"
|
||||
return f"{mb} MB"
|
||||
if value >= PB_SIZE:
|
||||
return f"{value / PB_SIZE:.2f} {labels[3]}"
|
||||
if value >= TB_SIZE:
|
||||
return f"{value / TB_SIZE:.2f} {labels[2]}"
|
||||
if value >= GB_SIZE:
|
||||
return f"{value / GB_SIZE:.2f} {labels[1]}"
|
||||
return f"{value} {labels[0]}"
|
||||
|
||||
|
||||
@register.filter()
|
||||
def humanize_disk_megabytes(mb):
|
||||
def humanize_disk_capacity(value):
|
||||
"""
|
||||
Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.).
|
||||
Use the DISK_BASE_UNIT setting to determine the divisor. Default is 1000.
|
||||
Express a disk capacity in the most suitable unit, using the DISK_BASE_UNIT
|
||||
setting to select SI (MB/GB) or IEC (MiB/GiB) labels.
|
||||
"""
|
||||
return _humanize_megabytes(mb, DISK_BASE_UNIT)
|
||||
return _humanize_capacity(value, DISK_BASE_UNIT)
|
||||
|
||||
|
||||
@register.filter()
|
||||
def humanize_ram_megabytes(mb):
|
||||
def humanize_ram_capacity(value):
|
||||
"""
|
||||
Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.).
|
||||
Use the RAM_BASE_UNIT setting to determine the divisor. Default is 1000.
|
||||
Express a RAM capacity in the most suitable unit, using the RAM_BASE_UNIT
|
||||
setting to select SI (MB/GB) or IEC (MiB/GiB) labels.
|
||||
"""
|
||||
return _humanize_megabytes(mb, RAM_BASE_UNIT)
|
||||
return _humanize_capacity(value, RAM_BASE_UNIT)
|
||||
|
||||
|
||||
@register.filter()
|
||||
@@ -481,6 +491,35 @@ def applied_filters(context, model, form, query_params):
|
||||
'link_text': link_text,
|
||||
})
|
||||
|
||||
# Handle empty modifier pills separately. `FilterModifierWidget.value_from_datadict()`
|
||||
# returns None for fields with a `field__empty` query parameter so that the underlying
|
||||
# form field does not attempt to validate 'true'/'false' as a real field value (which
|
||||
# would raise a ValidationError for ModelChoiceField). Because the value is None, these
|
||||
# fields never appear in `form.changed_data`, so we build their pills directly from the
|
||||
# query parameters here.
|
||||
for param_name, param_value in query_params.items():
|
||||
if not param_name.endswith('__empty'):
|
||||
continue
|
||||
field_name = param_name[:-len('__empty')]
|
||||
if field_name not in form.fields or field_name == 'filter_id':
|
||||
continue
|
||||
|
||||
querydict = query_params.copy()
|
||||
querydict.pop(param_name)
|
||||
label = form.fields[field_name].label or field_name
|
||||
|
||||
if param_value.lower() in ('true', '1'):
|
||||
link_text = f'{label} {_("is empty")}'
|
||||
else:
|
||||
link_text = f'{label} {_("is not empty")}'
|
||||
|
||||
applied_filters.append({
|
||||
'name': param_name,
|
||||
'value': param_value,
|
||||
'link_url': f'?{querydict.urlencode()}',
|
||||
'link_text': link_text,
|
||||
})
|
||||
|
||||
save_link = None
|
||||
if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
|
||||
object_type = ObjectType.objects.get_for_model(model).pk
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .api import *
|
||||
from .base import *
|
||||
from .filtersets import *
|
||||
from .tables import *
|
||||
from .utils import *
|
||||
from .views import *
|
||||
|
||||
130
netbox/utilities/testing/tables.py
Normal file
130
netbox/utilities/testing/tables.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import inspect
|
||||
from importlib import import_module
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
from netbox.views import generic
|
||||
|
||||
from .base import TestCase
|
||||
|
||||
__all__ = (
|
||||
"ModelTableTestCase",
|
||||
"TableTestCases",
|
||||
)
|
||||
|
||||
|
||||
class ModelTableTestCase(TestCase):
|
||||
"""
|
||||
Shared helpers for model-backed table tests.
|
||||
|
||||
Concrete subclasses should set `table` and may override `get_queryset()`
|
||||
or `excluded_orderable_columns` as needed.
|
||||
"""
|
||||
table = None
|
||||
excluded_orderable_columns = frozenset({"actions"})
|
||||
|
||||
# Optional explicit override for odd cases
|
||||
queryset_sources = None
|
||||
|
||||
# Only these view types are considered sortable queryset sources by default
|
||||
queryset_source_view_classes = (generic.ObjectListView,)
|
||||
|
||||
@classmethod
|
||||
def validate_table_test_case(cls):
|
||||
if cls.table is None:
|
||||
raise AssertionError(f"{cls.__name__} must define `table`")
|
||||
if getattr(cls.table._meta, "model", None) is None:
|
||||
raise AssertionError(f"{cls.__name__}.table must be model-backed")
|
||||
|
||||
def get_request(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
return request
|
||||
|
||||
def get_table(self, queryset):
|
||||
return self.table(queryset)
|
||||
|
||||
@classmethod
|
||||
def is_queryset_source_view(cls, view):
|
||||
model = cls.table._meta.model
|
||||
app_label = model._meta.app_label
|
||||
|
||||
return (
|
||||
inspect.isclass(view)
|
||||
and view.__module__.startswith(f"{app_label}.views")
|
||||
and getattr(view, "table", None) is cls.table
|
||||
and getattr(view, "queryset", None) is not None
|
||||
and issubclass(view, cls.queryset_source_view_classes)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_queryset_sources(cls):
|
||||
"""
|
||||
Return iterable of (label, queryset) pairs to test.
|
||||
|
||||
By default, only discover list-style views that declare this table.
|
||||
That keeps bulk edit/delete confirmation tables out of the ordering
|
||||
smoke test.
|
||||
"""
|
||||
if cls.queryset_sources is not None:
|
||||
return tuple(cls.queryset_sources)
|
||||
|
||||
model = cls.table._meta.model
|
||||
app_label = model._meta.app_label
|
||||
module = import_module(f"{app_label}.views")
|
||||
|
||||
sources = []
|
||||
for _, view in inspect.getmembers(module, inspect.isclass):
|
||||
if not cls.is_queryset_source_view(view):
|
||||
continue
|
||||
|
||||
queryset = view.queryset
|
||||
if hasattr(queryset, "all"):
|
||||
queryset = queryset.all()
|
||||
|
||||
sources.append((view.__name__, queryset))
|
||||
|
||||
if not sources:
|
||||
raise AssertionError(
|
||||
f"{cls.__name__} could not find any list-style queryset source for "
|
||||
f"{cls.table.__module__}.{cls.table.__name__}; "
|
||||
"set `queryset_sources` explicitly if needed."
|
||||
)
|
||||
|
||||
return tuple(sources)
|
||||
|
||||
def iter_orderable_columns(self, queryset):
|
||||
for column in self.get_table(queryset).columns:
|
||||
if not column.orderable:
|
||||
continue
|
||||
if column.name in self.excluded_orderable_columns:
|
||||
continue
|
||||
yield column.name
|
||||
|
||||
|
||||
class TableTestCases:
|
||||
"""
|
||||
Keep test_* methods nested to avoid unittest auto-discovering the reusable
|
||||
base classes directly.
|
||||
"""
|
||||
|
||||
class OrderableColumnsTestCase(ModelTableTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.validate_table_test_case()
|
||||
|
||||
def test_every_orderable_column_renders(self):
|
||||
request = self.get_request()
|
||||
|
||||
for source_name, queryset in self.get_queryset_sources():
|
||||
for column_name in self.iter_orderable_columns(queryset):
|
||||
for direction, prefix in (("asc", ""), ("desc", "-")):
|
||||
with self.cleanupSubTest(
|
||||
source=source_name,
|
||||
column=column_name,
|
||||
direction=direction,
|
||||
):
|
||||
table = self.get_table(queryset)
|
||||
table.order_by = f"{prefix}{column_name}"
|
||||
table.as_html(request)
|
||||
@@ -6,8 +6,11 @@ from django.template import Context
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
import dcim.filtersets # noqa: F401 - Import to register Device filterset
|
||||
from dcim.forms.filtersets import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from core.models import ObjectType
|
||||
from dcim.forms.filtersets import DeviceFilterForm, SiteFilterForm
|
||||
from dcim.models import Device, Manufacturer, Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.filtersets import BaseFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
@@ -338,3 +341,70 @@ class EmptyLookupTest(TestCase):
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
filter_pill = result['applied_filters'][0]
|
||||
self.assertIn('not empty', filter_pill['link_text'].lower())
|
||||
|
||||
|
||||
class ObjectCustomFieldEmptyLookupTest(TestCase):
|
||||
"""
|
||||
Regression test for https://github.com/netbox-community/netbox/issues/21535.
|
||||
|
||||
Rendering a filter form with an object-type custom field and the __empty modifier
|
||||
must not raise a ValueError or produce a form validation error.
|
||||
Filter pills must still appear for the empty modifier.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create(username='test_user_obj_cf')
|
||||
site_type = ObjectType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
name='test_obj_cf',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
related_object_type=ObjectType.objects.get_for_model(Manufacturer),
|
||||
)
|
||||
cf.save()
|
||||
cf.object_types.set([site_type])
|
||||
|
||||
def _make_form_and_result(self, querystring):
|
||||
query_params = QueryDict(querystring)
|
||||
form = SiteFilterForm(query_params)
|
||||
request = RequestFactory().get('/', query_params)
|
||||
request.user = self.user
|
||||
context = Context({'request': request})
|
||||
result = applied_filters(context, Site, form, query_params)
|
||||
return form, result
|
||||
|
||||
def test_render_form_with_empty_true_no_error(self):
|
||||
"""Rendering SiteFilterForm with cf__empty=true must not raise ValueError."""
|
||||
query_params = QueryDict('cf_test_obj_cf__empty=true')
|
||||
form = SiteFilterForm(query_params)
|
||||
try:
|
||||
str(form['cf_test_obj_cf'])
|
||||
except ValueError as e:
|
||||
self.fail(f"Rendering object-type custom field with __empty=true raised ValueError: {e}")
|
||||
|
||||
def test_render_form_with_empty_false_no_error(self):
|
||||
"""Rendering SiteFilterForm with cf__empty=false must not raise ValueError."""
|
||||
query_params = QueryDict('cf_test_obj_cf__empty=false')
|
||||
form = SiteFilterForm(query_params)
|
||||
try:
|
||||
str(form['cf_test_obj_cf'])
|
||||
except ValueError as e:
|
||||
self.fail(f"Rendering object-type custom field with __empty=false raised ValueError: {e}")
|
||||
|
||||
def test_no_validation_error_on_empty_true(self):
|
||||
"""The filter form must not have a validation error for the field when __empty=true."""
|
||||
form, _ = self._make_form_and_result('cf_test_obj_cf__empty=true')
|
||||
form.is_valid()
|
||||
self.assertNotIn('cf_test_obj_cf', form.errors)
|
||||
|
||||
def test_filter_pill_appears_for_empty_true(self):
|
||||
"""A filter pill showing 'is empty' must be generated for an object-type CF with __empty=true."""
|
||||
_, result = self._make_form_and_result('cf_test_obj_cf__empty=true')
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
self.assertIn('empty', result['applied_filters'][0]['link_text'].lower())
|
||||
|
||||
def test_filter_pill_appears_for_empty_false(self):
|
||||
"""A filter pill showing 'is not empty' must be generated for an object-type CF with __empty=false."""
|
||||
_, result = self._make_form_and_result('cf_test_obj_cf__empty=false')
|
||||
self.assertGreater(len(result['applied_filters']), 0)
|
||||
self.assertIn('not empty', result['applied_filters'][0]['link_text'].lower())
|
||||
|
||||
@@ -6,7 +6,12 @@ from netbox.choices import ImportFormatChoices
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.forms.fields.csv import CSVSelectWidget
|
||||
from utilities.forms.forms import BulkRenameForm
|
||||
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, get_field_value
|
||||
from utilities.forms.utils import (
|
||||
expand_alphanumeric_pattern,
|
||||
expand_ipaddress_pattern,
|
||||
get_capacity_unit_label,
|
||||
get_field_value,
|
||||
)
|
||||
from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
|
||||
|
||||
|
||||
@@ -550,3 +555,15 @@ class SelectMultipleWidgetTest(TestCase):
|
||||
self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
|
||||
self.assertEqual(widget.choices[1][0], 'Group B')
|
||||
self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])
|
||||
|
||||
|
||||
class GetCapacityUnitLabelTest(TestCase):
|
||||
"""
|
||||
Test the get_capacity_unit_label function for correct base unit label.
|
||||
"""
|
||||
|
||||
def test_si_label(self):
|
||||
self.assertEqual(get_capacity_unit_label(1000), 'MB')
|
||||
|
||||
def test_iec_label(self):
|
||||
self.assertEqual(get_capacity_unit_label(1024), 'MiB')
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from utilities.templatetags.builtins.tags import static_with_params
|
||||
from utilities.templatetags.helpers import _humanize_capacity
|
||||
|
||||
|
||||
class StaticWithParamsTest(TestCase):
|
||||
@@ -46,3 +47,46 @@ class StaticWithParamsTest(TestCase):
|
||||
# Check that new parameter value is used
|
||||
self.assertIn('v=new_version', result)
|
||||
self.assertNotIn('v=old_version', result)
|
||||
|
||||
|
||||
class HumanizeCapacityTest(TestCase):
|
||||
"""
|
||||
Test the _humanize_capacity function for correct SI/IEC unit label selection.
|
||||
"""
|
||||
|
||||
# Tests with divisor=1000 (SI/decimal units)
|
||||
|
||||
def test_si_megabytes(self):
|
||||
self.assertEqual(_humanize_capacity(500, divisor=1000), '500 MB')
|
||||
|
||||
def test_si_gigabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2000, divisor=1000), '2.00 GB')
|
||||
|
||||
def test_si_terabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2000000, divisor=1000), '2.00 TB')
|
||||
|
||||
def test_si_petabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2000000000, divisor=1000), '2.00 PB')
|
||||
|
||||
# Tests with divisor=1024 (IEC/binary units)
|
||||
|
||||
def test_iec_megabytes(self):
|
||||
self.assertEqual(_humanize_capacity(500, divisor=1024), '500 MiB')
|
||||
|
||||
def test_iec_gigabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2048, divisor=1024), '2.00 GiB')
|
||||
|
||||
def test_iec_terabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2097152, divisor=1024), '2.00 TiB')
|
||||
|
||||
def test_iec_petabytes(self):
|
||||
self.assertEqual(_humanize_capacity(2147483648, divisor=1024), '2.00 PiB')
|
||||
|
||||
# Edge cases
|
||||
|
||||
def test_empty_value(self):
|
||||
self.assertEqual(_humanize_capacity(0, divisor=1000), '')
|
||||
self.assertEqual(_humanize_capacity(None, divisor=1000), '')
|
||||
|
||||
def test_default_divisor_is_1000(self):
|
||||
self.assertEqual(_humanize_capacity(2000), '2.00 GB')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
@@ -13,6 +14,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import BulkRenameForm, add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.utils import get_capacity_unit_label
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
@@ -138,11 +140,11 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
|
||||
)
|
||||
memory = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Memory (MB)')
|
||||
label=_('Memory')
|
||||
)
|
||||
disk = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Disk (MB)')
|
||||
label=_('Disk')
|
||||
)
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
@@ -159,6 +161,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
|
||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set unit labels based on configured RAM_BASE_UNIT / DISK_BASE_UNIT (MB vs MiB)
|
||||
self.fields['memory'].label = _('Memory ({unit})').format(unit=get_capacity_unit_label(settings.RAM_BASE_UNIT))
|
||||
self.fields['disk'].label = _('Disk ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT))
|
||||
|
||||
|
||||
class VMInterfaceBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
|
||||
virtual_machine = forms.ModelChoiceField(
|
||||
@@ -304,7 +313,7 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
|
||||
)
|
||||
size = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Size (MB)')
|
||||
label=_('Size')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
@@ -318,6 +327,12 @@ class VirtualDiskBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set unit label based on configured DISK_BASE_UNIT (MB vs MiB)
|
||||
self.fields['size'].label = _('Size ({unit})').format(unit=get_capacity_unit_label(settings.DISK_BASE_UNIT))
|
||||
|
||||
|
||||
class VirtualDiskBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user