mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-27 04:08:14 +01:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5f91fcfe | ||
|
|
1a2175127e | ||
|
|
e859807d1d | ||
|
|
a8c997ff29 | ||
|
|
4a28ab98f4 | ||
|
|
3636d55017 | ||
|
|
aa69e96818 | ||
|
|
a9e50238eb | ||
|
|
a9a300197a | ||
|
|
3dcca73ecc | ||
|
|
4b4c542dce | ||
|
|
077d9b1129 | ||
|
|
e81ccb9be6 | ||
|
|
bc83d04c8f | ||
|
|
339ad455e4 | ||
|
|
f24376cfab |
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: 🏁 Performance
|
||||
type: Performance
|
||||
description: An opportunity to improve application performance
|
||||
labels: ["netbox", "type: performance", "status: needs triage"]
|
||||
body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Python Version
|
||||
description: What version of Python are you currently running?
|
||||
options:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Area(s) of Concern
|
||||
description: Which application interface(s) are affected?
|
||||
options:
|
||||
- label: User Interface
|
||||
- label: REST API
|
||||
- label: GraphQL API
|
||||
- label: Python ORM
|
||||
- label: Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: >
|
||||
Describe in detail the operations being performed and the indications of a performance issue.
|
||||
Include any relevant testing parameters, benchmarks, and expected results.
|
||||
validations:
|
||||
required: true
|
||||
@@ -3,29 +3,41 @@
|
||||
NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
||||
|
||||
```
|
||||
./manage.py nbshell
|
||||
cd /opt/netbox
|
||||
source /opt/netbox/venv/bin/activate
|
||||
python3 netbox/manage.py nbshell
|
||||
```
|
||||
|
||||
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
|
||||
```
|
||||
$ ./manage.py nbshell
|
||||
(venv) $ python3 netbox/manage.py nbshell
|
||||
### NetBox interactive shell (localhost)
|
||||
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
|
||||
### lsmodels() will show available models. Use help(<model>) for more info.
|
||||
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
|
||||
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
|
||||
```
|
||||
|
||||
The function `lsmodels()` will print a list of all available NetBox models:
|
||||
|
||||
```
|
||||
>>> lsmodels()
|
||||
DCIM:
|
||||
ConsolePort
|
||||
ConsolePortTemplate
|
||||
ConsoleServerPort
|
||||
ConsoleServerPortTemplate
|
||||
Device
|
||||
...
|
||||
DCIM:
|
||||
dcim.Cable
|
||||
dcim.CableTermination
|
||||
dcim.ConsolePort
|
||||
dcim.ConsolePortTemplate
|
||||
dcim.ConsoleServerPort
|
||||
dcim.ConsoleServerPortTemplate
|
||||
dcim.Device
|
||||
...
|
||||
```
|
||||
|
||||
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
|
||||
|
||||
```
|
||||
>>> exit()
|
||||
(venv) $
|
||||
```
|
||||
|
||||
!!! warning
|
||||
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
|
||||
>>> Device.objects.filter(interfaces__name="em0")
|
||||
```
|
||||
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
|
||||
|
||||
```
|
||||
>>> Device.objects.filter(name__icontains="testdevice")
|
||||
|
||||
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
|
||||
|
||||
```no-highlight
|
||||
$ psql --username netbox --password --host localhost netbox
|
||||
Password for user netbox:
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
Password:
|
||||
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
netbox=> \conninfo
|
||||
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
netbox=> \q
|
||||
```
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
!!! note
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Enumerating objects: 996, done.
|
||||
remote: Counting objects: 100% (996/996), done.
|
||||
remote: Compressing objects: 100% (935/935), done.
|
||||
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
|
||||
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
||||
Resolving deltas: 100% (148/148), done.
|
||||
remote: Enumerating objects: 148317, done.
|
||||
remote: Counting objects: 100% (183/183), done.
|
||||
remote: Compressing objects: 100% (115/115), done.
|
||||
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
|
||||
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
|
||||
Resolving deltas: 100% (116428/116428), done.
|
||||
```
|
||||
|
||||
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
|
||||
@@ -102,7 +102,8 @@ sudo cp configuration_example.py configuration.py
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
|
||||
|
||||
* `ALLOWED_HOSTS`
|
||||
* `DATABASES` (or `DATABASE`)
|
||||
* `API_TOKEN_PEPPERS`
|
||||
* `DATABASES`
|
||||
* `REDIS`
|
||||
* `SECRET_KEY`
|
||||
|
||||
@@ -158,7 +159,7 @@ DATABASES = {
|
||||
|
||||
### REDIS
|
||||
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
|
||||
Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
|
||||
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
|
||||
|
||||
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
|
||||
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
|
||||
|
||||
```no-highlight
|
||||
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
|
||||
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
If successful, you should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
Watching for file changes with StatReloader
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
August 30, 2021 - 18:02:23
|
||||
Django version 3.2.6, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
January 26, 2026 - 17:00:00
|
||||
Django version 5.2.10, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
|
||||
@@ -43,16 +43,22 @@ You should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
|
||||
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
|
||||
Docs: https://docs.netbox.dev/
|
||||
Main PID: 1140492 (gunicorn)
|
||||
Tasks: 19 (limit: 4683)
|
||||
Memory: 666.2M
|
||||
Main PID: 7283 (gunicorn)
|
||||
Tasks: 6 (limit: 4545)
|
||||
Memory: 556.1M (peak: 556.3M)
|
||||
CPU: 3.387s
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
└─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
|
||||
Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
</div>
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
1. [Redis](2-redis.md)
|
||||
2. [Redis](2-redis.md)
|
||||
3. [NetBox components](3-netbox.md)
|
||||
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
|
||||
5. [HTTP server](5-http-server.md)
|
||||
|
||||
@@ -65,7 +65,7 @@ Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
# Set $NEWVER to the NetBox version being installed
|
||||
NEWVER=3.5.0
|
||||
NEWVER=4.5.0
|
||||
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
|
||||
sudo tar -xzf v$NEWVER.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
|
||||
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
||||
|
||||
```no-highlight
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
OLDVER=3.4.9
|
||||
OLDVER=4.4.10
|
||||
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
|
||||
```
|
||||
cd /opt/netbox && \
|
||||
sudo git fetch --tags && \
|
||||
sudo git checkout v4.2.7
|
||||
sudo git checkout v4.5.0
|
||||
```
|
||||
|
||||
## 4. Run the Upgrade Script
|
||||
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
|
||||
```
|
||||
|
||||
!!! warning
|
||||
If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
|
||||
If the default version of Python is not **at least 3.12**, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
|
||||
|
||||
```no-highlight
|
||||
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.db import connection, models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.context import query_cache
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
from utilities.string import title
|
||||
@@ -70,6 +71,12 @@ class ObjectTypeManager(models.Manager):
|
||||
"""
|
||||
from netbox.models.features import get_model_features, model_is_public
|
||||
|
||||
# Check the request cache before hitting the database
|
||||
cache = query_cache.get()
|
||||
if cache is not None:
|
||||
if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
|
||||
return ot
|
||||
|
||||
# TODO: Remove this in NetBox v5.0
|
||||
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
|
||||
# fall back to ContentType.
|
||||
@@ -96,6 +103,10 @@ class ObjectTypeManager(models.Manager):
|
||||
features=get_model_features(model),
|
||||
)[0]
|
||||
|
||||
# Populate the request cache to avoid redundant lookups
|
||||
if cache is not None:
|
||||
cache['object_types'][(model._meta.model, for_concrete_model)] = ot
|
||||
|
||||
return ot
|
||||
|
||||
def get_for_models(self, *models, for_concrete_models=True):
|
||||
|
||||
@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if self.instance and scope_type_id != self.instance.scope_type_id:
|
||||
if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
|
||||
self.initial['scope'] = None
|
||||
|
||||
else:
|
||||
|
||||
@@ -20,7 +20,9 @@ from utilities.forms.fields import (
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from utilities.forms.widgets import (
|
||||
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
|
||||
)
|
||||
from utilities.jsonschema import JSONSchemaProperty
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
|
||||
},
|
||||
)
|
||||
)
|
||||
face = forms.ChoiceField(
|
||||
label=_('Face'),
|
||||
choices=add_blank_choice(DeviceFaceChoices),
|
||||
required=False,
|
||||
widget=ClearableSelect(
|
||||
requires_fields=['rack']
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
label=_('Device type'),
|
||||
queryset=DeviceType.objects.all(),
|
||||
|
||||
@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'description': self.description,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'airflow': self.airflow,
|
||||
'attribute_data': self.attribute_data,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections import UserDict, defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -12,7 +12,6 @@ from core.models import ObjectType
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.models.features import has_feature
|
||||
from users.models import User
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.request import copy_safe_request
|
||||
from utilities.rqworker import get_rq_retry
|
||||
@@ -23,6 +22,21 @@ from .models import EventRule
|
||||
logger = logging.getLogger('netbox.events_processor')
|
||||
|
||||
|
||||
class EventContext(UserDict):
|
||||
"""
|
||||
A custom dictionary that automatically serializes its associated object on demand.
|
||||
"""
|
||||
|
||||
# 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 __getitem__(self, item):
|
||||
if item == 'data' and 'data' not in self:
|
||||
data = serialize_for_event(self['object'])
|
||||
self.__setitem__('data', data)
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
def serialize_for_event(instance):
|
||||
"""
|
||||
Return a serialized representation of the given instance suitable for use in a queued event.
|
||||
@@ -66,37 +80,42 @@ 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]['data'] = serialize_for_event(instance)
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
|
||||
# If the object is being deleted, update any prior "update" event to "delete"
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['event_type'] = event_type
|
||||
else:
|
||||
queue[key] = {
|
||||
'object_type': ObjectType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event_type': event_type,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, event_type),
|
||||
'request': request,
|
||||
queue[key] = EventContext(
|
||||
object_type=ObjectType.objects.get_for_model(instance),
|
||||
object_id=instance.pk,
|
||||
object=instance,
|
||||
event_type=event_type,
|
||||
snapshots=get_snapshots(instance, event_type),
|
||||
request=request,
|
||||
user=request.user,
|
||||
# Legacy request attributes for backward compatibility
|
||||
'username': request.user.username,
|
||||
'request_id': request.id,
|
||||
}
|
||||
username=request.user.username,
|
||||
request_id=request.id,
|
||||
)
|
||||
# Force serialization of objects prior to them actually being deleted
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
|
||||
|
||||
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
|
||||
user = User.objects.get(username=username) if username else None
|
||||
def process_event_rules(event_rules, object_type, event):
|
||||
"""
|
||||
Process a list of EventRules against an event.
|
||||
"""
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
||||
# Evaluate event rule conditions (if any)
|
||||
if not event_rule.eval_conditions(data):
|
||||
if not event_rule.eval_conditions(event['data']):
|
||||
continue
|
||||
|
||||
# Compile event data
|
||||
event_data = event_rule.action_data or {}
|
||||
event_data.update(data)
|
||||
event_data.update(event['data'])
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
@@ -109,25 +128,20 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
params = {
|
||||
"event_rule": event_rule,
|
||||
"object_type": object_type,
|
||||
"event_type": event_type,
|
||||
"event_type": event['event_type'],
|
||||
"data": event_data,
|
||||
"snapshots": snapshots,
|
||||
"snapshots": event.get('snapshots'),
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"username": username,
|
||||
"username": event['username'],
|
||||
"retry": get_rq_retry()
|
||||
}
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
if 'request' in event:
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
# which can cause pickle errors with Pillow.
|
||||
params["request"] = copy_safe_request(request, include_files=False)
|
||||
params['request'] = copy_safe_request(event['request'], include_files=False)
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks.send_webhook",
|
||||
**params
|
||||
)
|
||||
rq_queue.enqueue('extras.webhooks.send_webhook', **params)
|
||||
|
||||
# Scripts
|
||||
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
|
||||
@@ -139,16 +153,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
params = {
|
||||
"instance": event_rule.action_object,
|
||||
"name": script.name,
|
||||
"user": user,
|
||||
"user": event['user'],
|
||||
"data": event_data
|
||||
}
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
params["request"] = copy_safe_request(request)
|
||||
ScriptJob.enqueue(
|
||||
**params
|
||||
)
|
||||
if 'snapshots' in event:
|
||||
params['snapshots'] = event['snapshots']
|
||||
if 'request' in event:
|
||||
params['request'] = copy_safe_request(event['request'])
|
||||
|
||||
# Enqueue the job
|
||||
ScriptJob.enqueue(**params)
|
||||
|
||||
# Notification groups
|
||||
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
|
||||
@@ -157,7 +171,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
object_type=object_type,
|
||||
object_id=event_data['id'],
|
||||
object_repr=event_data.get('display'),
|
||||
event_type=event_type
|
||||
event_type=event['event_type']
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -169,6 +183,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
def process_event_queue(events):
|
||||
"""
|
||||
Flush a list of object representation to RQ for EventRule processing.
|
||||
|
||||
This is the default processor listed in EVENTS_PIPELINE.
|
||||
"""
|
||||
events_cache = defaultdict(dict)
|
||||
|
||||
@@ -188,11 +204,7 @@ def process_event_queue(events):
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=object_type,
|
||||
event_type=event['event_type'],
|
||||
data=event['data'],
|
||||
username=event['username'],
|
||||
snapshots=event['snapshots'],
|
||||
request=event['request'],
|
||||
event=event,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.dispatch import receiver
|
||||
|
||||
from core.events import *
|
||||
from core.signals import job_end, job_start
|
||||
from extras.events import process_event_rules
|
||||
from extras.events import EventContext, process_event_rules
|
||||
from extras.models import EventRule, Notification, Subscription
|
||||
from netbox.config import get_config
|
||||
from netbox.models.features import has_feature
|
||||
@@ -102,14 +102,12 @@ def process_job_start_event_rules(sender, **kwargs):
|
||||
enabled=True,
|
||||
object_types=sender.object_type
|
||||
)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event = EventContext(
|
||||
event_type=JOB_STARTED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
user=sender.user,
|
||||
)
|
||||
process_event_rules(event_rules, sender.object_type, event)
|
||||
|
||||
|
||||
@receiver(job_end)
|
||||
@@ -122,14 +120,12 @@ def process_job_end_event_rules(sender, **kwargs):
|
||||
enabled=True,
|
||||
object_types=sender.object_type
|
||||
)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event = EventContext(
|
||||
event_type=JOB_COMPLETED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
user=sender.user,
|
||||
)
|
||||
process_event_rules(event_rules, sender.object_type, event)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
|
||||
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
|
||||
<i class="mdi mdi-image"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
|
||||
"""
|
||||
|
||||
NOTIFICATION_ICON = """
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.forms import ValidationError
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from core.models import DataSource, ObjectType
|
||||
from core.models import AutoSyncRecord, DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -754,3 +754,53 @@ class ConfigTemplateTest(TestCase):
|
||||
@tag('regression')
|
||||
def test_config_template_with_data_source_nested_templates(self):
|
||||
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
|
||||
|
||||
@tag('regression')
|
||||
def test_autosyncrecord_cleanup_on_detach(self):
|
||||
"""Test that AutoSyncRecord is deleted when detaching from DataSource."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
templates_dir = Path(temp_dir) / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._create_template_file(templates_dir, 'test.j2', 'Test content')
|
||||
|
||||
data_source = DataSource(
|
||||
name="Test DataSource for Detach",
|
||||
type="local",
|
||||
source_url=str(templates_dir),
|
||||
)
|
||||
data_source.save()
|
||||
data_source.sync()
|
||||
|
||||
data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
|
||||
|
||||
# Create a ConfigTemplate with data_file and auto_sync_enabled
|
||||
config_template = ConfigTemplate(
|
||||
name="TestTemplateForDetach",
|
||||
data_file=data_file,
|
||||
auto_sync_enabled=True
|
||||
)
|
||||
config_template.clean()
|
||||
config_template.save()
|
||||
|
||||
# Verify AutoSyncRecord was created
|
||||
object_type = ObjectType.objects.get_for_model(ConfigTemplate)
|
||||
autosync_records = AutoSyncRecord.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=config_template.pk
|
||||
)
|
||||
self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
|
||||
|
||||
# Detach from DataSource
|
||||
config_template.data_file = None
|
||||
config_template.data_source = None
|
||||
config_template.auto_sync_enabled = False
|
||||
config_template.clean()
|
||||
config_template.save()
|
||||
|
||||
# Verify AutoSyncRecord was deleted
|
||||
autosync_records = AutoSyncRecord.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=config_template.pk
|
||||
)
|
||||
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
|
||||
|
||||
@@ -3,8 +3,10 @@ from contextvars import ContextVar
|
||||
__all__ = (
|
||||
'current_request',
|
||||
'events_queue',
|
||||
'query_cache',
|
||||
)
|
||||
|
||||
|
||||
current_request = ContextVar('current_request', default=None)
|
||||
events_queue = ContextVar('events_queue', default=dict())
|
||||
query_cache = ContextVar('query_cache', default=None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.context import current_request, events_queue, query_cache
|
||||
from netbox.utils import register_request_processor
|
||||
from extras.events import flush_events
|
||||
|
||||
@@ -16,6 +17,7 @@ def event_tracking(request):
|
||||
"""
|
||||
current_request.set(request)
|
||||
events_queue.set({})
|
||||
query_cache.set(defaultdict(dict))
|
||||
|
||||
yield
|
||||
|
||||
@@ -26,3 +28,4 @@ def event_tracking(request):
|
||||
# Clear context vars
|
||||
current_request.set(None)
|
||||
events_queue.set({})
|
||||
query_cache.set(None)
|
||||
|
||||
@@ -569,7 +569,6 @@ class SyncedDataMixin(models.Model):
|
||||
)
|
||||
else:
|
||||
AutoSyncRecord.objects.filter(
|
||||
datafile=self.data_file,
|
||||
object_type=object_type,
|
||||
object_id=self.pk
|
||||
).delete()
|
||||
@@ -582,7 +581,6 @@ class SyncedDataMixin(models.Model):
|
||||
# Delete AutoSyncRecord
|
||||
object_type = ObjectType.objects.get_for_model(self)
|
||||
AutoSyncRecord.objects.filter(
|
||||
datafile=self.data_file,
|
||||
object_type=object_type,
|
||||
object_id=self.pk
|
||||
).delete()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from functools import cache
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Authentication'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='users:user_list',
|
||||
link_text=_('Users'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_user'],
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link='users:user_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['users.add_user']
|
||||
),
|
||||
MenuItemButton(
|
||||
link='users:user_bulk_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['users.add_user']
|
||||
)
|
||||
)
|
||||
),
|
||||
MenuItem(
|
||||
link='users:group_list',
|
||||
link_text=_('Groups'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_group'],
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link='users:group_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['users.add_group']
|
||||
),
|
||||
MenuItemButton(
|
||||
link='users:group_bulk_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['users.add_group']
|
||||
)
|
||||
)
|
||||
),
|
||||
MenuItem(
|
||||
link='users:token_list',
|
||||
link_text=_('API Tokens'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_token'],
|
||||
buttons=get_model_buttons('users', 'token')
|
||||
),
|
||||
MenuItem(
|
||||
link='users:objectpermission_list',
|
||||
link_text=_('Permissions'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_objectpermission'],
|
||||
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
|
||||
),
|
||||
get_model_item('users', 'user', _('Users')),
|
||||
get_model_item('users', 'group', _('Groups')),
|
||||
get_model_item('users', 'token', _('API Tokens')),
|
||||
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
@@ -501,40 +453,49 @@ ADMIN_MENU = Menu(
|
||||
),
|
||||
)
|
||||
|
||||
MENUS = [
|
||||
ORGANIZATION_MENU,
|
||||
RACKS_MENU,
|
||||
DEVICES_MENU,
|
||||
CONNECTIONS_MENU,
|
||||
WIRELESS_MENU,
|
||||
IPAM_MENU,
|
||||
VPN_MENU,
|
||||
VIRTUALIZATION_MENU,
|
||||
CIRCUITS_MENU,
|
||||
POWER_MENU,
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
]
|
||||
|
||||
# Add top-level plugin menus
|
||||
for menu in registry['plugins']['menus']:
|
||||
MENUS.append(menu)
|
||||
|
||||
# Add the default "plugins" menu
|
||||
if registry['plugins']['menu_items']:
|
||||
|
||||
# Build the default plugins menu
|
||||
groups = [
|
||||
MenuGroup(label=label, items=items)
|
||||
for label, items in registry['plugins']['menu_items'].items()
|
||||
@cache
|
||||
def get_menus():
|
||||
"""
|
||||
Dynamically build and return the list of navigation menus.
|
||||
This ensures plugin menus registered during app initialization are included.
|
||||
The result is cached since menus don't change without a Django restart.
|
||||
"""
|
||||
menus = [
|
||||
ORGANIZATION_MENU,
|
||||
RACKS_MENU,
|
||||
DEVICES_MENU,
|
||||
CONNECTIONS_MENU,
|
||||
WIRELESS_MENU,
|
||||
IPAM_MENU,
|
||||
VPN_MENU,
|
||||
VIRTUALIZATION_MENU,
|
||||
CIRCUITS_MENU,
|
||||
POWER_MENU,
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
]
|
||||
plugins_menu = Menu(
|
||||
label=_("Plugins"),
|
||||
icon_class="mdi mdi-puzzle",
|
||||
groups=groups
|
||||
)
|
||||
MENUS.append(plugins_menu)
|
||||
|
||||
# Add the admin menu last
|
||||
MENUS.append(ADMIN_MENU)
|
||||
# Add top-level plugin menus
|
||||
for menu in registry['plugins']['menus']:
|
||||
menus.append(menu)
|
||||
|
||||
# Add the default "plugins" menu
|
||||
if registry['plugins']['menu_items']:
|
||||
# Build the default plugins menu
|
||||
groups = [
|
||||
MenuGroup(label=label, items=items)
|
||||
for label, items in registry['plugins']['menu_items'].items()
|
||||
]
|
||||
plugins_menu = Menu(
|
||||
label=_("Plugins"),
|
||||
icon_class="mdi mdi-puzzle",
|
||||
groups=groups
|
||||
)
|
||||
menus.append(plugins_menu)
|
||||
|
||||
# Add the admin menu last
|
||||
menus.append(ADMIN_MENU)
|
||||
|
||||
return menus
|
||||
|
||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js.map
vendored
8
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
40
netbox/project-static/src/forms/clearField.ts
Normal file
40
netbox/project-static/src/forms/clearField.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import TomSelect from 'tom-select';
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Initialize clear-field dependencies.
|
||||
* When a required field is cleared, dependent fields with data-requires-fields attribute will also be cleared.
|
||||
*/
|
||||
export function initClearField(): void {
|
||||
// Find all fields with data-requires-fields attribute
|
||||
for (const field of getElements<HTMLSelectElement>('[data-requires-fields]')) {
|
||||
const requiredFieldsAttr = field.getAttribute('data-requires-fields');
|
||||
if (!requiredFieldsAttr) continue;
|
||||
|
||||
// Parse the comma-separated list of required field names
|
||||
const requiredFields = requiredFieldsAttr.split(',').map(name => name.trim());
|
||||
|
||||
// Set up listeners for each required field
|
||||
for (const requiredFieldName of requiredFields) {
|
||||
const requiredField = document.querySelector<HTMLSelectElement>(
|
||||
`[name="${requiredFieldName}"]`,
|
||||
);
|
||||
if (!requiredField) continue;
|
||||
|
||||
// Listen for changes on the required field
|
||||
requiredField.addEventListener('change', () => {
|
||||
// If required field is cleared, also clear this dependent field
|
||||
if (!requiredField.value || requiredField.value === '') {
|
||||
// Check if this field uses TomSelect
|
||||
const tomselect = (field as HTMLSelectElement & { tomselect?: TomSelect }).tomselect;
|
||||
if (tomselect) {
|
||||
tomselect.clear();
|
||||
} else {
|
||||
// Regular select field
|
||||
field.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { initClearField } from './clearField';
|
||||
import { initFormElements } from './elements';
|
||||
import { initFilterModifiers } from './filterModifiers';
|
||||
import { initSpeedSelector } from './speedSelector';
|
||||
|
||||
export function initForms(): void {
|
||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
|
||||
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'ipam/inc/toggle_available.html' %}
|
||||
{% include 'ipam/inc/max_depth.html' %}
|
||||
{% include 'ipam/inc/max_length.html' %}
|
||||
{% if perms.ipam.add_prefix and first_available_prefix %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
|
||||
|
||||
20
netbox/templates/ipam/inc/max_depth.html
Normal file
20
netbox/templates/ipam/inc/max_depth.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_depth" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
{% trans "Max Depth" %}{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="max_depth">
|
||||
{% if request.GET.depth__lte %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=None page=1 %}">{% trans "Clear" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in 16|as_range %}
|
||||
<li><a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=i page=1 %}">
|
||||
{{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
20
netbox/templates/ipam/inc/max_length.html
Normal file
20
netbox/templates/ipam/inc/max_length.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_length" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
{% trans "Max Length" %}{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="max_length">
|
||||
{% if request.GET.mask_length__lte %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=None page=1 %}">{% trans "Clear" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
|
||||
<li><a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=i page=1 %}">
|
||||
{{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'ipam/inc/toggle_available.html' %}
|
||||
{% include 'ipam/inc/max_depth.html' %}
|
||||
{% include 'ipam/inc/max_length.html' %}
|
||||
{% if perms.ipam.add_prefix and first_available_prefix %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
|
||||
|
||||
@@ -6,38 +6,6 @@
|
||||
<button class="btn btn-outline-secondary toggle-depth" type="button">
|
||||
{% trans "Hide Depth Indicators" %}
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_depth" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
{% trans "Max Depth" %}{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="max_depth">
|
||||
{% if request.GET.depth__lte %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=None page=1 %}">{% trans "Clear" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in 16|as_range %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=i page=1 %}">
|
||||
{{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_length" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
|
||||
{% trans "Max Length" %}{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="max_length">
|
||||
{% if request.GET.mask_length__lte %}
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=None page=1 %}">{% trans "Clear" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=i page=1 %}">
|
||||
{{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% include 'ipam/inc/max_depth.html' %}
|
||||
{% include 'ipam/inc/max_length.html' %}
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from ..utils import add_blank_choice
|
||||
|
||||
__all__ = (
|
||||
'BulkEditNullBooleanSelect',
|
||||
'ClearableSelect',
|
||||
'ColorSelect',
|
||||
'HTMXSelect',
|
||||
'SelectWithPK',
|
||||
@@ -28,6 +29,21 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
|
||||
)
|
||||
|
||||
|
||||
class ClearableSelect(forms.Select):
|
||||
"""
|
||||
A Select widget that will be automatically cleared when one or more required fields are cleared.
|
||||
|
||||
Args:
|
||||
requires_fields: A list of field names that this field depends on. When any of these fields
|
||||
are cleared, this field will also be cleared automatically via JavaScript.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, requires_fields=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if requires_fields:
|
||||
self.attrs['data-requires-fields'] = ','.join(requires_fields)
|
||||
|
||||
|
||||
class ColorSelect(forms.Select):
|
||||
"""
|
||||
Extends the built-in Select widget to colorize each <option>.
|
||||
|
||||
@@ -252,3 +252,16 @@ def isodatetime(value, spec='seconds'):
|
||||
else:
|
||||
return ''
|
||||
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
|
||||
|
||||
|
||||
@register.filter
|
||||
def truncate_middle(value, length):
|
||||
if len(value) <= length:
|
||||
return value
|
||||
|
||||
# Calculate split points for the two parts
|
||||
half_len = (length - 1) // 2 # 1 for the ellipsis
|
||||
first_part = value[:half_len]
|
||||
second_part = value[len(value) - (length - 1 - half_len):]
|
||||
|
||||
return mark_safe(f"{first_part}…{second_part}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import template
|
||||
|
||||
from netbox.navigation.menu import MENUS
|
||||
from netbox.navigation.menu import get_menus
|
||||
|
||||
__all__ = (
|
||||
'nav',
|
||||
@@ -19,7 +19,7 @@ def nav(context):
|
||||
nav_items = []
|
||||
|
||||
# Construct the navigation menu based upon the current user's permissions
|
||||
for menu in MENUS:
|
||||
for menu in get_menus():
|
||||
groups = []
|
||||
for group in menu.groups:
|
||||
items = []
|
||||
|
||||
Reference in New Issue
Block a user