mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
2a4e3dd09f
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.8
|
placeholder: v3.5.9
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.5.8
|
placeholder: v3.5.9
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -332,6 +332,7 @@
|
|||||||
"100gbase-x-cfp",
|
"100gbase-x-cfp",
|
||||||
"100gbase-x-cfp2",
|
"100gbase-x-cfp2",
|
||||||
"200gbase-x-cfp2",
|
"200gbase-x-cfp2",
|
||||||
|
"400gbase-x-cfp2",
|
||||||
"100gbase-x-cfp4",
|
"100gbase-x-cfp4",
|
||||||
"100gbase-x-cxp",
|
"100gbase-x-cxp",
|
||||||
"100gbase-x-cpak",
|
"100gbase-x-cpak",
|
||||||
|
@ -111,7 +111,7 @@ The following methods are available to log results within a report:
|
|||||||
|
|
||||||
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
|
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
|
||||||
|
|
||||||
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`.
|
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
|
||||||
|
|
||||||
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
|
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
|||||||
|
|
||||||
```no-highlight
|
```no-highlight
|
||||||
# Set $OLDVER to the NetBox version currently installed
|
# Set $OLDVER to the NetBox version currently installed
|
||||||
NEWVER=3.4.9
|
OLDVER=3.4.9
|
||||||
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
|
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/configuration.py /opt/netbox/netbox/netbox/
|
||||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||||
|
@ -62,12 +62,13 @@ These lookup expressions can be applied by adding a suffix to the desired field'
|
|||||||
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
||||||
|
|
||||||
| Filter | Description |
|
| Filter | Description |
|
||||||
|--------|-------------|
|
|---------|--------------------------|
|
||||||
| `n` | Not equal to |
|
| `n` | Not equal to |
|
||||||
| `lt` | Less than |
|
| `lt` | Less than |
|
||||||
| `lte` | Less than or equal to |
|
| `lte` | Less than or equal to |
|
||||||
| `gt` | Greater than |
|
| `gt` | Greater than |
|
||||||
| `gte` | Greater than or equal to |
|
| `gte` | Greater than or equal to |
|
||||||
|
| `empty` | Is empty/null (boolean) |
|
||||||
|
|
||||||
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
||||||
|
|
||||||
@ -80,7 +81,7 @@ GET /api/ipam/vlans/?vid__gt=900
|
|||||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||||
|
|
||||||
| Filter | Description |
|
| Filter | Description |
|
||||||
|--------|-------------|
|
|---------|----------------------------------------|
|
||||||
| `n` | Not equal to |
|
| `n` | Not equal to |
|
||||||
| `ic` | Contains (case-insensitive) |
|
| `ic` | Contains (case-insensitive) |
|
||||||
| `nic` | Does not contain (case-insensitive) |
|
| `nic` | Does not contain (case-insensitive) |
|
||||||
@ -90,7 +91,7 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
|
|||||||
| `niew` | Does not end with (case-insensitive) |
|
| `niew` | Does not end with (case-insensitive) |
|
||||||
| `ie` | Exact match (case-insensitive) |
|
| `ie` | Exact match (case-insensitive) |
|
||||||
| `nie` | Inverse exact match (case-insensitive) |
|
| `nie` | Inverse exact match (case-insensitive) |
|
||||||
| `empty` | Is empty (boolean) |
|
| `empty` | Is empty/null (boolean) |
|
||||||
|
|
||||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||||
|
|
||||||
|
@ -1,6 +1,32 @@
|
|||||||
# NetBox v3.5
|
# NetBox v3.5
|
||||||
|
|
||||||
## v3.5.9 (FUTURE)
|
## v3.5.9 (2023-08-28)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#12489](https://github.com/netbox-community/netbox/issues/12489) - Dynamically render location and device lists under site and location views
|
||||||
|
* [#12825](https://github.com/netbox-community/netbox/issues/12825) - Display assigned values count per obejct type under custom field view
|
||||||
|
* [#13313](https://github.com/netbox-community/netbox/issues/13313) - Enable filtering IP ranges by containing prefix
|
||||||
|
* [#13415](https://github.com/netbox-community/netbox/issues/13415) - Include request object in custom link renderer on tables
|
||||||
|
* [#13536](https://github.com/netbox-community/netbox/issues/13536) - Move child VLANs list to a separate tab under VLAN group view
|
||||||
|
* [#13542](https://github.com/netbox-community/netbox/issues/13542) - Pass additional HTTP headers through to custom script context
|
||||||
|
* [#13585](https://github.com/netbox-community/netbox/issues/13585) - Introduce `empty` lookup for numeric value filters
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#11272](https://github.com/netbox-community/netbox/issues/11272) - Fix localization support for device position field
|
||||||
|
* [#13358](https://github.com/netbox-community/netbox/issues/13358) - Git backend should send HTTP auth headers only if credentials have been defined
|
||||||
|
* [#13477](https://github.com/netbox-community/netbox/issues/13477) - Fix filtering of modified objects after bulk import/update
|
||||||
|
* [#13478](https://github.com/netbox-community/netbox/issues/13478) - Fix filtering of export templates by content type under web UI
|
||||||
|
* [#13500](https://github.com/netbox-community/netbox/issues/13500) - Fix form validation for bulk update of L2VPN terminations via bulk import form
|
||||||
|
* [#13503](https://github.com/netbox-community/netbox/issues/13503) - Fix utilization graph proportions when localization is enabled
|
||||||
|
* [#13507](https://github.com/netbox-community/netbox/issues/13507) - Avoid raising exception for invalid content type during global search
|
||||||
|
* [#13516](https://github.com/netbox-community/netbox/issues/13516) - Plugin utility functions should be importable from `extras.plugins`
|
||||||
|
* [#13530](https://github.com/netbox-community/netbox/issues/13530) - Ensure script log messages can be serialized as JSON data
|
||||||
|
* [#13543](https://github.com/netbox-community/netbox/issues/13543) - Config context tab under device/VM view should not require `extras.view_configcontext` permission
|
||||||
|
* [#13544](https://github.com/netbox-community/netbox/issues/13544) - Ensure `reindex` command clears all cached values when not in lazy mode
|
||||||
|
* [#13556](https://github.com/netbox-community/netbox/issues/13556) - Correct REST API representation of VDC status choice
|
||||||
|
* [#13569](https://github.com/netbox-community/netbox/issues/13569) - Fix selection widgets for related interfaces when bulk editing interfaces under device view
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -125,6 +125,7 @@ class GitBackend(DataBackend):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.url_scheme in ('http', 'https'):
|
if self.url_scheme in ('http', 'https'):
|
||||||
|
if self.params.get('username'):
|
||||||
clone_args.update(
|
clone_args.update(
|
||||||
{
|
{
|
||||||
"username": self.params.get('username'),
|
"username": self.params.get('username'),
|
||||||
|
@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||||
|
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
||||||
|
|
||||||
# Related object counts
|
# Related object counts
|
||||||
interface_count = serializers.IntegerField(read_only=True)
|
interface_count = serializers.IntegerField(read_only=True)
|
||||||
|
@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
label=_('Position'),
|
label=_('Position'),
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_("The lowest-numbered unit occupied by the device"),
|
help_text=_("The lowest-numbered unit occupied by the device"),
|
||||||
|
localize=True,
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||||
attrs={
|
attrs={
|
||||||
'disabled-indicator': 'device',
|
'disabled-indicator': 'device',
|
||||||
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
|
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
device_type = DynamicModelChoiceField(
|
device_type = DynamicModelChoiceField(
|
||||||
|
@ -398,32 +398,8 @@ class SiteView(generic.ObjectView):
|
|||||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
locations = Location.objects.add_related_count(
|
|
||||||
Location.objects.all(),
|
|
||||||
Rack,
|
|
||||||
'location',
|
|
||||||
'rack_count',
|
|
||||||
cumulative=True
|
|
||||||
)
|
|
||||||
locations = Location.objects.add_related_count(
|
|
||||||
locations,
|
|
||||||
Device,
|
|
||||||
'location',
|
|
||||||
'device_count',
|
|
||||||
cumulative=True
|
|
||||||
).restrict(request.user, 'view').filter(site=instance)
|
|
||||||
|
|
||||||
nonracked_devices = Device.objects.filter(
|
|
||||||
site=instance,
|
|
||||||
rack__isnull=True,
|
|
||||||
parent_bay__isnull=True
|
|
||||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': related_models,
|
||||||
'locations': locations,
|
|
||||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
|
||||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -495,16 +471,8 @@ class LocationView(generic.ObjectView):
|
|||||||
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
nonracked_devices = Device.objects.filter(
|
|
||||||
location=instance,
|
|
||||||
rack__isnull=True,
|
|
||||||
parent_bay__isnull=True
|
|
||||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': related_models,
|
||||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
|
||||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -2055,7 +2023,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
|||||||
base_template = 'dcim/device/base.html'
|
base_template = 'dcim/device/base.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Config Context'),
|
label=_('Config Context'),
|
||||||
permission='extras.view_configcontext',
|
|
||||||
weight=2000
|
weight=2000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
(_('Data'), ('data_source_id', 'data_file_id')),
|
(_('Data'), ('data_source_id', 'data_file_id')),
|
||||||
(_('Attributes'), ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
(_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
|
||||||
)
|
)
|
||||||
data_source_id = DynamicModelMultipleChoiceField(
|
data_source_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=DataSource.objects.all(),
|
queryset=DataSource.objects.all(),
|
||||||
@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
'source_id': '$data_source_id'
|
'source_id': '$data_source_id'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||||
required=False
|
required=False,
|
||||||
|
label=_('Content types')
|
||||||
)
|
)
|
||||||
mime_type = forms.CharField(
|
mime_type = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -69,10 +69,7 @@ class Command(BaseCommand):
|
|||||||
if not kwargs['lazy']:
|
if not kwargs['lazy']:
|
||||||
self.stdout.write('Clearing cached values... ', ending='')
|
self.stdout.write('Clearing cached values... ', ending='')
|
||||||
self.stdout.flush()
|
self.stdout.flush()
|
||||||
content_types = [
|
deleted_count = search_backend.clear()
|
||||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
|
||||||
]
|
|
||||||
deleted_count = search_backend.clear(content_types)
|
|
||||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||||
|
|
||||||
# Index models
|
# Index models
|
||||||
|
@ -11,6 +11,7 @@ from netbox.search import register_search
|
|||||||
from .navigation import *
|
from .navigation import *
|
||||||
from .registration import *
|
from .registration import *
|
||||||
from .templates import *
|
from .templates import *
|
||||||
|
from .utils import *
|
||||||
|
|
||||||
# Initialize plugin registry
|
# Initialize plugin registry
|
||||||
registry['plugins'].update({
|
registry['plugins'].update({
|
||||||
|
@ -401,23 +401,23 @@ class BaseScript:
|
|||||||
|
|
||||||
def log_debug(self, message):
|
def log_debug(self, message):
|
||||||
self.logger.log(logging.DEBUG, message)
|
self.logger.log(logging.DEBUG, message)
|
||||||
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
|
self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
|
||||||
|
|
||||||
def log_success(self, message):
|
def log_success(self, message):
|
||||||
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
|
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
|
||||||
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
|
self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
|
||||||
|
|
||||||
def log_info(self, message):
|
def log_info(self, message):
|
||||||
self.logger.log(logging.INFO, message)
|
self.logger.log(logging.INFO, message)
|
||||||
self.log.append((LogLevelChoices.LOG_INFO, message))
|
self.log.append((LogLevelChoices.LOG_INFO, str(message)))
|
||||||
|
|
||||||
def log_warning(self, message):
|
def log_warning(self, message):
|
||||||
self.logger.log(logging.WARNING, message)
|
self.logger.log(logging.WARNING, message)
|
||||||
self.log.append((LogLevelChoices.LOG_WARNING, message))
|
self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
|
||||||
|
|
||||||
def log_failure(self, message):
|
def log_failure(self, message):
|
||||||
self.logger.log(logging.ERROR, message)
|
self.logger.log(logging.ERROR, message)
|
||||||
self.log.append((LogLevelChoices.LOG_FAILURE, message))
|
self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
|
||||||
|
|
||||||
# Convenience functions
|
# Convenience functions
|
||||||
|
|
||||||
|
@ -1109,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||||||
Site(name='Site 1', slug='site-1'),
|
Site(name='Site 1', slug='site-1'),
|
||||||
Site(name='Site 2', slug='site-2'),
|
Site(name='Site 2', slug='site-2'),
|
||||||
Site(name='Site 3', slug='site-3'),
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
Site(name='Site 4', slug='site-4'),
|
||||||
)
|
)
|
||||||
Site.objects.bulk_create(sites)
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
# Simulate *creation* changelog records for two of the sites
|
# Simulate *creation* changelog records for two of the sites
|
||||||
request_id = uuid.uuid4()
|
request_id = uuid.uuid4()
|
||||||
|
cls.create_request_id = request_id
|
||||||
objectchanges = (
|
objectchanges = (
|
||||||
ObjectChange(
|
ObjectChange(
|
||||||
changed_object_type=content_type,
|
changed_object_type=content_type,
|
||||||
@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||||||
|
|
||||||
# Simulate *update* changelog records for two of the sites
|
# Simulate *update* changelog records for two of the sites
|
||||||
request_id = uuid.uuid4()
|
request_id = uuid.uuid4()
|
||||||
|
cls.update_request_id = request_id
|
||||||
objectchanges = (
|
objectchanges = (
|
||||||
ObjectChange(
|
ObjectChange(
|
||||||
changed_object_type=content_type,
|
changed_object_type=content_type,
|
||||||
@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
ObjectChange.objects.bulk_create(objectchanges)
|
ObjectChange.objects.bulk_create(objectchanges)
|
||||||
|
|
||||||
|
# Simulate *create* and *update* changelog records for two of the sites
|
||||||
|
request_id = uuid.uuid4()
|
||||||
|
cls.create_update_request_id = request_id
|
||||||
|
objectchanges = (
|
||||||
|
ObjectChange(
|
||||||
|
changed_object_type=content_type,
|
||||||
|
changed_object_id=sites[2].pk,
|
||||||
|
action=ObjectChangeActionChoices.ACTION_CREATE,
|
||||||
|
request_id=request_id
|
||||||
|
),
|
||||||
|
ObjectChange(
|
||||||
|
changed_object_type=content_type,
|
||||||
|
changed_object_id=sites[3].pk,
|
||||||
|
action=ObjectChangeActionChoices.ACTION_UPDATE,
|
||||||
|
request_id=request_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ObjectChange.objects.bulk_create(objectchanges)
|
||||||
|
|
||||||
def test_created_by_request(self):
|
def test_created_by_request(self):
|
||||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id
|
params = {'created_by_request': self.create_request_id}
|
||||||
params = {'created_by_request': request_id}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.queryset.count(), 3)
|
self.assertEqual(self.queryset.count(), 4)
|
||||||
|
|
||||||
def test_updated_by_request(self):
|
def test_updated_by_request(self):
|
||||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id
|
params = {'updated_by_request': self.update_request_id}
|
||||||
params = {'updated_by_request': request_id}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.queryset.count(), 3)
|
self.assertEqual(self.queryset.count(), 4)
|
||||||
|
|
||||||
|
def test_modified_by_request(self):
|
||||||
|
params = {'modified_by_request': self.create_update_request_id}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.queryset.count(), 4)
|
||||||
|
@ -46,6 +46,21 @@ class CustomFieldListView(generic.ObjectListView):
|
|||||||
class CustomFieldView(generic.ObjectView):
|
class CustomFieldView(generic.ObjectView):
|
||||||
queryset = CustomField.objects.select_related('choice_set')
|
queryset = CustomField.objects.select_related('choice_set')
|
||||||
|
|
||||||
|
def get_extra_context(self, request, instance):
|
||||||
|
related_models = ()
|
||||||
|
|
||||||
|
for content_type in instance.content_types.all():
|
||||||
|
related_models += (
|
||||||
|
content_type.model_class().objects.restrict(request.user, 'view').exclude(
|
||||||
|
Q(**{f'custom_field_data__{instance.name}': ''}) |
|
||||||
|
Q(**{f'custom_field_data__{instance.name}': None})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'related_models': related_models
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CustomField, 'edit')
|
@register_model_view(CustomField, 'edit')
|
||||||
class CustomFieldEditView(generic.ObjectEditView):
|
class CustomFieldEditView(generic.ObjectEditView):
|
||||||
|
@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
choices=IPRangeStatusChoices,
|
choices=IPRangeStatusChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
|
parent = MultiValueCharFilter(
|
||||||
|
method='search_by_parent',
|
||||||
|
label=_('Parent prefix'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IPRange
|
model = IPRange
|
||||||
@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
def search_by_parent(self, queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
q = Q()
|
||||||
|
for prefix in value:
|
||||||
|
try:
|
||||||
|
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
|
||||||
|
q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
|
||||||
|
except (AddrFormatError, ValueError):
|
||||||
|
return queryset.none()
|
||||||
|
return queryset.filter(q)
|
||||||
|
|
||||||
|
|
||||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||||
family = django_filters.NumberFilter(
|
family = django_filters.NumberFilter(
|
||||||
|
@ -592,9 +592,11 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
|
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
|
||||||
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
|
raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
|
||||||
if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
|
if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
|
||||||
raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
|
raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
|
||||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
|
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
|
||||||
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
|
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
|
||||||
|
|
||||||
|
# if this is an update we might not have interface or vlan in the form data
|
||||||
|
if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
|
||||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
|
||||||
|
@ -118,6 +118,12 @@ class VLANGroup(OrganizationalModel):
|
|||||||
return available_vids[0]
|
return available_vids[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_child_vlans(self):
|
||||||
|
"""
|
||||||
|
Return all VLANs within this group.
|
||||||
|
"""
|
||||||
|
return VLAN.objects.filter(group=self).order_by('vid')
|
||||||
|
|
||||||
|
|
||||||
class VLAN(PrimaryModel):
|
class VLAN(PrimaryModel):
|
||||||
"""
|
"""
|
||||||
|
@ -10,7 +10,6 @@ from ipam.models import *
|
|||||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
@ -807,6 +806,12 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'description': ['foobar1', 'foobar2']}
|
params = {'description': ['foobar1', 'foobar2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_parent(self):
|
||||||
|
params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = IPAddress.objects.all()
|
queryset = IPAddress.objects.all()
|
||||||
|
@ -897,21 +897,8 @@ class VLANGroupView(generic.ObjectView):
|
|||||||
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Replace with embedded table
|
|
||||||
vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
|
|
||||||
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
|
|
||||||
'tenant', 'site', 'role',
|
|
||||||
).order_by('vid')
|
|
||||||
vlans = add_available_vlans(vlans, vlan_group=instance)
|
|
||||||
|
|
||||||
vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',))
|
|
||||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
|
||||||
vlans_table.columns.show('pk')
|
|
||||||
vlans_table.configure(request)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': related_models,
|
||||||
'vlans_table': vlans_table,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -944,6 +931,30 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.VLANGroupTable
|
table = tables.VLANGroupTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(VLANGroup, 'vlans')
|
||||||
|
class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||||
|
queryset = VLANGroup.objects.all()
|
||||||
|
child_model = VLAN
|
||||||
|
table = tables.VLANTable
|
||||||
|
filterset = filtersets.VLANFilterSet
|
||||||
|
template_name = 'generic/object_children.html'
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('VLANs'),
|
||||||
|
badge=lambda x: x.get_child_vlans().count(),
|
||||||
|
permission='ipam.view_vlan',
|
||||||
|
weight=500
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_children(self, request, parent):
|
||||||
|
return parent.get_child_vlans().restrict(request.user, 'view').prefetch_related(
|
||||||
|
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
|
||||||
|
'tenant', 'site', 'role',
|
||||||
|
)
|
||||||
|
|
||||||
|
def prep_table_data(self, request, queryset, parent):
|
||||||
|
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# FHRP groups
|
# FHRP groups
|
||||||
#
|
#
|
||||||
|
@ -246,18 +246,22 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
|
|||||||
updated_by_request = django_filters.UUIDFilter(
|
updated_by_request = django_filters.UUIDFilter(
|
||||||
method='filter_by_request'
|
method='filter_by_request'
|
||||||
)
|
)
|
||||||
|
modified_by_request = django_filters.UUIDFilter(
|
||||||
|
method='filter_by_request'
|
||||||
|
)
|
||||||
|
|
||||||
def filter_by_request(self, queryset, name, value):
|
def filter_by_request(self, queryset, name, value):
|
||||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||||
action = {
|
action = {
|
||||||
'created_by_request': ObjectChangeActionChoices.ACTION_CREATE,
|
'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
|
||||||
'updated_by_request': ObjectChangeActionChoices.ACTION_UPDATE,
|
'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
|
||||||
|
'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]),
|
||||||
}.get(name)
|
}.get(name)
|
||||||
request_id = value
|
request_id = value
|
||||||
pks = ObjectChange.objects.filter(
|
pks = ObjectChange.objects.filter(
|
||||||
|
action,
|
||||||
changed_object_type=content_type,
|
changed_object_type=content_type,
|
||||||
action=action,
|
request_id=request_id,
|
||||||
request_id=request_id
|
|
||||||
).values_list('changed_object_id', flat=True)
|
).values_list('changed_object_id', flat=True)
|
||||||
return queryset.filter(pk__in=pks)
|
return queryset.filter(pk__in=pks)
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.context_processors import auth
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.db.models import DateField, DateTimeField
|
from django.db.models import DateField, DateTimeField
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
@ -517,24 +518,32 @@ class CustomLinkColumn(tables.Column):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def render(self, record):
|
def _render_customlink(self, record, table):
|
||||||
try:
|
context = {
|
||||||
rendered = self.customlink.render({
|
|
||||||
'object': record,
|
'object': record,
|
||||||
|
'debug': settings.DEBUG,
|
||||||
|
}
|
||||||
|
if request := getattr(table, 'context', {}).get('request'):
|
||||||
|
# If the request is available, include it as context
|
||||||
|
context.update({
|
||||||
|
'request': request,
|
||||||
|
**auth(request),
|
||||||
})
|
})
|
||||||
if rendered:
|
|
||||||
|
return self.customlink.render(context)
|
||||||
|
|
||||||
|
def render(self, record, table, **kwargs):
|
||||||
|
try:
|
||||||
|
if rendered := self._render_customlink(record, table):
|
||||||
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
|
return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_text = _('Error')
|
error_text = _('Error')
|
||||||
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
|
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def value(self, record):
|
def value(self, record, table, **kwargs):
|
||||||
try:
|
try:
|
||||||
rendered = self.customlink.render({
|
if rendered := self._render_customlink(record, table):
|
||||||
'object': record,
|
|
||||||
})
|
|
||||||
if rendered:
|
|
||||||
return rendered['link']
|
return rendered['link']
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
@ -465,7 +465,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
|
|
||||||
view_name = get_viewname(model, action='list')
|
view_name = get_viewname(model, action='list')
|
||||||
results_url = f"{reverse(view_name)}?created_by_request={request.id}"
|
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
|
||||||
return redirect(results_url)
|
return redirect(results_url)
|
||||||
|
|
||||||
except (AbortTransaction, ValidationError):
|
except (AbortTransaction, ValidationError):
|
||||||
|
BIN
netbox/project-static/dist/graphiql.css
vendored
BIN
netbox/project-static/dist/graphiql.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/graphiql.js
vendored
BIN
netbox/project-static/dist/graphiql.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/graphiql.js.map
vendored
BIN
netbox/project-static/dist/graphiql.js.map
vendored
Binary file not shown.
@ -6,7 +6,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graphiql": "1.4.1",
|
"graphiql": "1.8.9",
|
||||||
"graphql": ">= v14.5.0 <= 15.5.0",
|
"graphql": ">= v14.5.0 <= 15.5.0",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,15 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% block bulk_edit_controls %}
|
{% block bulk_edit_controls %}
|
||||||
{{ block.super }}
|
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
|
||||||
|
{% if 'bulk_edit' in actions and bulk_edit_view %}
|
||||||
|
<button type="submit" name="_edit"
|
||||||
|
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
|
||||||
|
class="btn btn-warning btn-sm">
|
||||||
|
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
|
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
|
||||||
{% if 'bulk_rename' in actions and bulk_rename_view %}
|
{% if 'bulk_rename' in actions and bulk_rename_view %}
|
||||||
<button type="submit" name="_rename"
|
<button type="submit" name="_rename"
|
||||||
|
@ -1,83 +0,0 @@
|
|||||||
{% load helpers %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">
|
|
||||||
{% trans "Non-Racked Devices" %}
|
|
||||||
</h5>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if nonracked_devices %}
|
|
||||||
<table class="table table-hover">
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Name" %}</th>
|
|
||||||
<th>{% trans "Role" %}</th>
|
|
||||||
<th>{% trans "Type" %}</th>
|
|
||||||
<th colspan="2">{% trans "Parent Device" %}</th>
|
|
||||||
</tr>
|
|
||||||
{% for device in nonracked_devices %}
|
|
||||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ device.role }}</td>
|
|
||||||
<td>{{ device.device_type }}</td>
|
|
||||||
{% if device.parent_bay %}
|
|
||||||
<td>{{ device.parent_bay.device|linkify }}</td>
|
|
||||||
<td>{{ device.parent_bay }}</td>
|
|
||||||
{% else %}
|
|
||||||
<td colspan="2" class="text-muted">—</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if total_nonracked_devices_count > nonracked_devices.count %}
|
|
||||||
{% if object|meta:'verbose_name' == 'site' %}
|
|
||||||
<div class="text-muted">
|
|
||||||
{% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
|
|
||||||
Displaying {{ count }} of {{ total }} devices
|
|
||||||
{% endblocktrans %}
|
|
||||||
(<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
|
|
||||||
</div>
|
|
||||||
{% elif object|meta:'verbose_name' == 'location' %}
|
|
||||||
<div class="text-muted">
|
|
||||||
{% blocktrans with count=nonracked_devices.count total=total_nonracked_devices_count %}
|
|
||||||
Displaying {{ count }} of {{ total }} devices
|
|
||||||
{% endblocktrans %}
|
|
||||||
(<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null">{% trans "View full list" %}</a>)
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-muted">
|
|
||||||
{% trans "None" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if perms.dcim.add_device %}
|
|
||||||
{% if object|meta:'verbose_name' == 'rack' %}
|
|
||||||
<div class="card-footer text-end noprint">
|
|
||||||
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&rack={{ object.pk }}" class="btn btn-primary btn-sm">
|
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
|
||||||
{% trans "Add a Non-Racked Device" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% elif object|meta:'verbose_name' == 'site' %}
|
|
||||||
<div class="card-footer text-end noprint">
|
|
||||||
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}" class="btn btn-primary btn-sm">
|
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
|
||||||
{% trans "Add a Non-Racked Device" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% elif object|meta:'verbose_name' == 'location' %}
|
|
||||||
<div class="card-footer text-end noprint">
|
|
||||||
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}" class="btn btn-primary btn-sm">
|
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
|
||||||
{% trans "Add a Non-Racked Device" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
@ -66,7 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
{% include 'inc/panels/related_objects.html' %}
|
{% include 'inc/panels/related_objects.html' %}
|
||||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
|
||||||
{% include 'inc/panels/image_attachments.html' %}
|
{% include 'inc/panels/image_attachments.html' %}
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
@ -79,6 +78,27 @@
|
|||||||
hx-get="{% url 'dcim:location_list' %}?parent_id={{ object.pk }}"
|
hx-get="{% url 'dcim:location_list' %}?parent_id={{ object.pk }}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
></div>
|
></div>
|
||||||
|
{% if perms.dcim.add_location %}
|
||||||
|
<div class="card-footer text-end noprint">
|
||||||
|
<a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Non-Racked Devices</h5>
|
||||||
|
<div class="card-body htmx-container table-responsive"
|
||||||
|
hx-get="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
|
||||||
|
hx-trigger="load"
|
||||||
|
></div>
|
||||||
|
{% if perms.dcim.add_device %}
|
||||||
|
<div class="card-footer text-end noprint">
|
||||||
|
<a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% plugin_full_width_page object %}
|
{% plugin_full_width_page object %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -132,56 +132,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
|
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">{% trans "Locations" %}</h5>
|
|
||||||
<div class='card-body'>
|
|
||||||
{% if locations %}
|
|
||||||
<table class="table table-hover">
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Location" %}</th>
|
|
||||||
<th>{% trans "Racks" %}</th>
|
|
||||||
<th>{% trans "Devices" %}</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
{% for location in locations %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{% for i in location.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
|
|
||||||
{{ location|linkify }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'dcim:rack_list' %}?location_id={{ location.pk }}">{{ location.rack_count }}</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'dcim:device_list' %}?location_id={{ location.pk }}">{{ location.device_count }}</a>
|
|
||||||
</td>
|
|
||||||
<td class="text-end noprint">
|
|
||||||
<a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ location.pk }}" class="btn btn-sm btn-primary" title="{% trans "View Elevations" %}">
|
|
||||||
<i class="mdi mdi-server"></i>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">{% trans "None" %}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if perms.dcim.add_location %}
|
|
||||||
<div class="card-footer text-end noprint">
|
|
||||||
<a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a location" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% include 'inc/panels/image_attachments.html' %}
|
{% include 'inc/panels/image_attachments.html' %}
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
<div class="card">
|
||||||
|
<h5 class="card-header">Locations</h5>
|
||||||
|
<div class="card-body htmx-container table-responsive"
|
||||||
|
hx-get="{% url 'dcim:location_list' %}?site_id={{ object.pk }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
></div>
|
||||||
|
{% if perms.dcim.add_location %}
|
||||||
|
<div class="card-footer text-end noprint">
|
||||||
|
<a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Non-Racked Devices</h5>
|
||||||
|
<div class="card-body htmx-container table-responsive"
|
||||||
|
hx-get="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null&parent_bay_id=null"
|
||||||
|
hx-trigger="load"
|
||||||
|
></div>
|
||||||
|
{% if perms.dcim.add_device %}
|
||||||
|
<div class="card-footer text-end noprint">
|
||||||
|
<a href="{% url 'dcim:device_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% plugin_full_width_page object %}
|
{% plugin_full_width_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -125,6 +125,24 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h5 class="card-header">Related Objects</h5>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% 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 bg-primary rounded-pill">{{ count }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light rounded-pill">—</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,15 +59,4 @@
|
|||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">{% trans "VLANs" %}</h5>
|
|
||||||
<div class="card-body table-responsive">
|
|
||||||
{% render_table vlans_table 'inc/table.html' %}
|
|
||||||
{% include 'inc/paginator.html' with paginator=vlans_table.paginator page=vlans_table.page %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -60,7 +60,13 @@ class TokenProvisionView(APIView):
|
|||||||
"""
|
"""
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
|
|
||||||
# @extend_schema(methods=["post"], responses={201: serializers.TokenSerializer})
|
@extend_schema(
|
||||||
|
request=serializers.TokenProvisionSerializer,
|
||||||
|
responses={
|
||||||
|
201: serializers.TokenSerializer,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
}
|
||||||
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = serializers.TokenProvisionSerializer(data=request.data)
|
serializer = serializers.TokenProvisionSerializer(data=request.data)
|
||||||
serializer.is_valid()
|
serializer.is_valid()
|
||||||
|
@ -20,7 +20,8 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
|||||||
lte='lte',
|
lte='lte',
|
||||||
lt='lt',
|
lt='lt',
|
||||||
gte='gte',
|
gte='gte',
|
||||||
gt='gt'
|
gt='gt',
|
||||||
|
empty='isnull',
|
||||||
)
|
)
|
||||||
|
|
||||||
FILTER_NEGATION_LOOKUP_MAP = dict(
|
FILTER_NEGATION_LOOKUP_MAP = dict(
|
||||||
@ -45,6 +46,10 @@ HTTP_REQUEST_META_SAFE_COPY = [
|
|||||||
'HTTP_REFERER',
|
'HTTP_REFERER',
|
||||||
'HTTP_USER_AGENT',
|
'HTTP_USER_AGENT',
|
||||||
'HTTP_X_FORWARDED_FOR',
|
'HTTP_X_FORWARDED_FOR',
|
||||||
|
'HTTP_X_FORWARDED_HOST',
|
||||||
|
'HTTP_X_FORWARDED_PORT',
|
||||||
|
'HTTP_X_FORWARDED_PROTO',
|
||||||
|
'HTTP_X_REAL_IP',
|
||||||
'QUERY_STRING',
|
'QUERY_STRING',
|
||||||
'REMOTE_ADDR',
|
'REMOTE_ADDR',
|
||||||
'REMOTE_HOST',
|
'REMOTE_HOST',
|
||||||
|
@ -105,6 +105,10 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
|||||||
# We avoid looking for values if either ct_id or fkey value is None
|
# We avoid looking for values if either ct_id or fkey value is None
|
||||||
ct_id = getattr(instance, ct_attname)
|
ct_id = getattr(instance, ct_attname)
|
||||||
if ct_id is not None:
|
if ct_id is not None:
|
||||||
|
# Check if the content type actually exists
|
||||||
|
if not self.get_content_type(id=ct_id, using=instance._state.db).model_class():
|
||||||
|
continue
|
||||||
|
|
||||||
fk_val = getattr(instance, self.fk_field)
|
fk_val = getattr(instance, self.fk_field)
|
||||||
if fk_val is not None:
|
if fk_val is not None:
|
||||||
fk_dict[ct_id].add(fk_val)
|
fk_dict[ct_id].add(fk_val)
|
||||||
@ -129,13 +133,14 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
|||||||
if ct_id is None:
|
if ct_id is None:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
model = self.get_content_type(
|
if model := self.get_content_type(
|
||||||
id=ct_id, using=obj._state.db
|
id=ct_id, using=obj._state.db
|
||||||
).model_class()
|
).model_class():
|
||||||
return (
|
return (
|
||||||
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
|
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
|
||||||
model,
|
model,
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
return (
|
return (
|
||||||
ret_val,
|
ret_val,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
{% load l10n %}
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
@ -5,7 +6,7 @@
|
|||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
aria-valuenow="{{ utilization }}"
|
aria-valuenow="{{ utilization }}"
|
||||||
class="progress-bar {{ bar_class }}"
|
class="progress-bar {{ bar_class }}"
|
||||||
style="width: {{ utilization }}%;"
|
style="width: {{ utilization|unlocalize }}%;"
|
||||||
>
|
>
|
||||||
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
|
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -86,6 +86,10 @@ class DummyModel(models.Model):
|
|||||||
charfield = models.CharField(
|
charfield = models.CharField(
|
||||||
max_length=10
|
max_length=10
|
||||||
)
|
)
|
||||||
|
numberfield = models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
choicefield = models.IntegerField(
|
choicefield = models.IntegerField(
|
||||||
choices=(('A', 1), ('B', 2), ('C', 3))
|
choices=(('A', 1), ('B', 2), ('C', 3))
|
||||||
)
|
)
|
||||||
@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
class DummyFilterSet(BaseFilterSet):
|
class DummyFilterSet(BaseFilterSet):
|
||||||
charfield = django_filters.CharFilter()
|
charfield = django_filters.CharFilter()
|
||||||
|
numberfield = django_filters.NumberFilter()
|
||||||
macaddressfield = MACAddressFilter()
|
macaddressfield = MACAddressFilter()
|
||||||
modelchoicefield = django_filters.ModelChoiceFilter(
|
modelchoicefield = django_filters.ModelChoiceFilter(
|
||||||
field_name='integerfield', # We're pretending this is a ForeignKey field
|
field_name='integerfield', # We're pretending this is a ForeignKey field
|
||||||
@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
|
|||||||
model = DummyModel
|
model = DummyModel
|
||||||
fields = (
|
fields = (
|
||||||
'charfield',
|
'charfield',
|
||||||
|
'numberfield',
|
||||||
'choicefield',
|
'choicefield',
|
||||||
'datefield',
|
'datefield',
|
||||||
'datetimefield',
|
'datetimefield',
|
||||||
@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
|
|||||||
self.assertEqual(self.filters['charfield__iew'].exclude, False)
|
self.assertEqual(self.filters['charfield__iew'].exclude, False)
|
||||||
self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
|
self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
|
||||||
self.assertEqual(self.filters['charfield__niew'].exclude, True)
|
self.assertEqual(self.filters['charfield__niew'].exclude, True)
|
||||||
|
self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
|
||||||
|
self.assertEqual(self.filters['charfield__empty'].exclude, False)
|
||||||
|
|
||||||
|
def test_number_filter(self):
|
||||||
|
self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
|
||||||
|
self.assertEqual(self.filters['numberfield'].lookup_expr, 'exact')
|
||||||
|
self.assertEqual(self.filters['numberfield'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['numberfield__n'].lookup_expr, 'exact')
|
||||||
|
self.assertEqual(self.filters['numberfield__n'].exclude, True)
|
||||||
|
self.assertEqual(self.filters['numberfield__lt'].lookup_expr, 'lt')
|
||||||
|
self.assertEqual(self.filters['numberfield__lt'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['numberfield__lte'].lookup_expr, 'lte')
|
||||||
|
self.assertEqual(self.filters['numberfield__lte'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['numberfield__gt'].lookup_expr, 'gt')
|
||||||
|
self.assertEqual(self.filters['numberfield__gt'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['numberfield__gte'].lookup_expr, 'gte')
|
||||||
|
self.assertEqual(self.filters['numberfield__gte'].exclude, False)
|
||||||
|
self.assertEqual(self.filters['numberfield__empty'].lookup_expr, 'isnull')
|
||||||
|
self.assertEqual(self.filters['numberfield__empty'].exclude, False)
|
||||||
|
|
||||||
def test_mac_address_filter(self):
|
def test_mac_address_filter(self):
|
||||||
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
|
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
|
||||||
|
@ -387,7 +387,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
|
|||||||
base_template = 'virtualization/virtualmachine.html'
|
base_template = 'virtualization/virtualmachine.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Config Context'),
|
label=_('Config Context'),
|
||||||
permission='extras.view_configcontext',
|
|
||||||
weight=2000
|
weight=2000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user