Merge branch 'develop' into feature
This commit is contained in:
commit
2a4e3dd09f
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.8
|
||||
placeholder: v3.5.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.8
|
||||
placeholder: v3.5.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@
|
|||
"100gbase-x-cfp",
|
||||
"100gbase-x-cfp2",
|
||||
"200gbase-x-cfp2",
|
||||
"400gbase-x-cfp2",
|
||||
"100gbase-x-cfp4",
|
||||
"100gbase-x-cxp",
|
||||
"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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
|||
|
||||
```no-highlight
|
||||
# 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/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
|
|
|
|||
|
|
@ -61,13 +61,14 @@ 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:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
| Filter | Description |
|
||||
|---------|--------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `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:
|
||||
|
||||
|
|
@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
|
|||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty (boolean) |
|
||||
| Filter | Description |
|
||||
|---------|----------------------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,32 @@
|
|||
# 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,12 +125,13 @@ class GitBackend(DataBackend):
|
|||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
if self.params.get('username'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Cloning git repo: {self.url}")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
|||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
|
|
|
|||
|
|
@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_("The lowest-numbered unit occupied by the device"),
|
||||
localize=True,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
attrs={
|
||||
'disabled-indicator': 'device',
|
||||
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
|
|
|
|||
|
|
@ -398,32 +398,8 @@ class SiteView(generic.ObjectView):
|
|||
(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 {
|
||||
'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'),
|
||||
)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
||||
|
||||
return {
|
||||
'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'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||
fieldsets = (
|
||||
(None, ('q', 'filter_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(
|
||||
queryset=DataSource.objects.all(),
|
||||
|
|
@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Content types'),
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
required=False,
|
||||
label=_('Content types')
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
required=False,
|
||||
|
|
|
|||
|
|
@ -69,10 +69,7 @@ class Command(BaseCommand):
|
|||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
deleted_count = search_backend.clear()
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from netbox.search import register_search
|
|||
from .navigation import *
|
||||
from .registration import *
|
||||
from .templates import *
|
||||
from .utils import *
|
||||
|
||||
# Initialize plugin registry
|
||||
registry['plugins'].update({
|
||||
|
|
|
|||
|
|
@ -401,23 +401,23 @@ class BaseScript:
|
|||
|
||||
def log_debug(self, 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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
self.logger.log(logging.ERROR, message)
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, message))
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
|
||||
|
||||
# Convenience functions
|
||||
|
||||
|
|
|
|||
|
|
@ -1109,11 +1109,13 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
# Simulate *creation* changelog records for two of the sites
|
||||
request_id = uuid.uuid4()
|
||||
cls.create_request_id = request_id
|
||||
objectchanges = (
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
|
|
@ -1132,6 +1134,7 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||
|
||||
# Simulate *update* changelog records for two of the sites
|
||||
request_id = uuid.uuid4()
|
||||
cls.update_request_id = request_id
|
||||
objectchanges = (
|
||||
ObjectChange(
|
||||
changed_object_type=content_type,
|
||||
|
|
@ -1148,14 +1151,36 @@ class ChangeLoggedFilterSetTestCase(TestCase):
|
|||
)
|
||||
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):
|
||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE).first().request_id
|
||||
params = {'created_by_request': request_id}
|
||||
params = {'created_by_request': self.create_request_id}
|
||||
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):
|
||||
request_id = ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE).first().request_id
|
||||
params = {'updated_by_request': request_id}
|
||||
params = {'updated_by_request': self.update_request_id}
|
||||
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):
|
||||
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')
|
||||
class CustomFieldEditView(generic.ObjectEditView):
|
||||
|
|
|
|||
|
|
@ -467,6 +467,10 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||
choices=IPRangeStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
parent = MultiValueCharFilter(
|
||||
method='search_by_parent',
|
||||
label=_('Parent prefix'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPRange
|
||||
|
|
@ -501,6 +505,18 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||
except ValidationError:
|
||||
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):
|
||||
family = django_filters.NumberFilter(
|
||||
|
|
|
|||
|
|
@ -592,9 +592,11 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
|
|||
|
||||
if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
|
||||
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.'))
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
|
||||
raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
|
||||
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('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')
|
||||
|
|
|
|||
|
|
@ -118,6 +118,12 @@ class VLANGroup(OrganizationalModel):
|
|||
return available_vids[0]
|
||||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from ipam.models import *
|
|||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
|
@ -807,6 +806,12 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||
params = {'description': ['foobar1', 'foobar2']}
|
||||
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):
|
||||
queryset = IPAddress.objects.all()
|
||||
|
|
|
|||
|
|
@ -897,21 +897,8 @@ class VLANGroupView(generic.ObjectView):
|
|||
(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 {
|
||||
'related_models': related_models,
|
||||
'vlans_table': vlans_table,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -944,6 +931,30 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
|||
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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -246,18 +246,22 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
|
|||
updated_by_request = django_filters.UUIDFilter(
|
||||
method='filter_by_request'
|
||||
)
|
||||
modified_by_request = django_filters.UUIDFilter(
|
||||
method='filter_by_request'
|
||||
)
|
||||
|
||||
def filter_by_request(self, queryset, name, value):
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
action = {
|
||||
'created_by_request': ObjectChangeActionChoices.ACTION_CREATE,
|
||||
'updated_by_request': ObjectChangeActionChoices.ACTION_UPDATE,
|
||||
'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
|
||||
'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
|
||||
'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]),
|
||||
}.get(name)
|
||||
request_id = value
|
||||
pks = ObjectChange.objects.filter(
|
||||
action,
|
||||
changed_object_type=content_type,
|
||||
action=action,
|
||||
request_id=request_id
|
||||
request_id=request_id,
|
||||
).values_list('changed_object_id', flat=True)
|
||||
return queryset.filter(pk__in=pks)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from urllib.parse import quote
|
|||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.context_processors import auth
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
|
|
@ -517,24 +518,32 @@ class CustomLinkColumn(tables.Column):
|
|||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def render(self, record):
|
||||
try:
|
||||
rendered = self.customlink.render({
|
||||
'object': record,
|
||||
def _render_customlink(self, record, table):
|
||||
context = {
|
||||
'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>')
|
||||
except Exception as e:
|
||||
error_text = _('Error')
|
||||
return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> {error_text}</span>')
|
||||
return ''
|
||||
|
||||
def value(self, record):
|
||||
def value(self, record, table, **kwargs):
|
||||
try:
|
||||
rendered = self.customlink.render({
|
||||
'object': record,
|
||||
})
|
||||
if rendered:
|
||||
if rendered := self._render_customlink(record, table):
|
||||
return rendered['link']
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||
messages.success(request, msg)
|
||||
|
||||
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)
|
||||
|
||||
except (AbortTransaction, ValidationError):
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@
|
|||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"graphiql": "1.4.1",
|
||||
"graphiql": "1.8.9",
|
||||
"graphql": ">= v14.5.0 <= 15.5.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,7 +2,15 @@
|
|||
{% load helpers %}
|
||||
|
||||
{% 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" %}
|
||||
{% if 'bulk_rename' in actions and bulk_rename_view %}
|
||||
<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 class="col col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
|
|
@ -79,6 +78,27 @@
|
|||
hx-get="{% url 'dcim:location_list' %}?parent_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.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>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -132,56 +132,40 @@
|
|||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% 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' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -125,6 +125,24 @@
|
|||
</table>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -59,15 +59,4 @@
|
|||
{% plugin_right_page object %}
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,13 @@ class TokenProvisionView(APIView):
|
|||
"""
|
||||
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):
|
||||
serializer = serializers.TokenProvisionSerializer(data=request.data)
|
||||
serializer.is_valid()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
|||
lte='lte',
|
||||
lt='lt',
|
||||
gte='gte',
|
||||
gt='gt'
|
||||
gt='gt',
|
||||
empty='isnull',
|
||||
)
|
||||
|
||||
FILTER_NEGATION_LOOKUP_MAP = dict(
|
||||
|
|
@ -45,6 +46,10 @@ HTTP_REQUEST_META_SAFE_COPY = [
|
|||
'HTTP_REFERER',
|
||||
'HTTP_USER_AGENT',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED_HOST',
|
||||
'HTTP_X_FORWARDED_PORT',
|
||||
'HTTP_X_FORWARDED_PROTO',
|
||||
'HTTP_X_REAL_IP',
|
||||
'QUERY_STRING',
|
||||
'REMOTE_ADDR',
|
||||
'REMOTE_HOST',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
|||
# We avoid looking for values if either ct_id or fkey value is None
|
||||
ct_id = getattr(instance, ct_attname)
|
||||
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)
|
||||
if fk_val is not None:
|
||||
fk_dict[ct_id].add(fk_val)
|
||||
|
|
@ -129,13 +133,14 @@ class RestrictedGenericForeignKey(GenericForeignKey):
|
|||
if ct_id is None:
|
||||
return None
|
||||
else:
|
||||
model = self.get_content_type(
|
||||
if model := self.get_content_type(
|
||||
id=ct_id, using=obj._state.db
|
||||
).model_class()
|
||||
return (
|
||||
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
|
||||
model,
|
||||
)
|
||||
).model_class():
|
||||
return (
|
||||
model._meta.pk.get_prep_value(getattr(obj, self.fk_field)),
|
||||
model,
|
||||
)
|
||||
return None
|
||||
|
||||
return (
|
||||
ret_val,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{% load l10n %}
|
||||
<div class="progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
|
|
@ -5,7 +6,7 @@
|
|||
aria-valuemax="100"
|
||||
aria-valuenow="{{ utilization }}"
|
||||
class="progress-bar {{ bar_class }}"
|
||||
style="width: {{ utilization }}%;"
|
||||
style="width: {{ utilization|unlocalize }}%;"
|
||||
>
|
||||
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ class DummyModel(models.Model):
|
|||
charfield = models.CharField(
|
||||
max_length=10
|
||||
)
|
||||
numberfield = models.IntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
choicefield = models.IntegerField(
|
||||
choices=(('A', 1), ('B', 2), ('C', 3))
|
||||
)
|
||||
|
|
@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
|
|||
"""
|
||||
class DummyFilterSet(BaseFilterSet):
|
||||
charfield = django_filters.CharFilter()
|
||||
numberfield = django_filters.NumberFilter()
|
||||
macaddressfield = MACAddressFilter()
|
||||
modelchoicefield = django_filters.ModelChoiceFilter(
|
||||
field_name='integerfield', # We're pretending this is a ForeignKey field
|
||||
|
|
@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
|
|||
model = DummyModel
|
||||
fields = (
|
||||
'charfield',
|
||||
'numberfield',
|
||||
'choicefield',
|
||||
'datefield',
|
||||
'datetimefield',
|
||||
|
|
@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
|
|||
self.assertEqual(self.filters['charfield__iew'].exclude, False)
|
||||
self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
|
||||
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):
|
||||
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
|
|||
base_template = 'virtualization/virtualmachine.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue