Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2023-08-29 10:45:55 -04:00
commit 2a4e3dd09f
42 changed files with 413 additions and 1008 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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.

View File

@ -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/

View File

@ -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:

View File

@ -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
---

View File

@ -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:

View File

@ -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)

View File

@ -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(

View File

@ -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
)

View File

@ -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,

View File

@ -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

View File

@ -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({

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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(

View File

@ -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')

View File

@ -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):
"""

View File

@ -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()

View File

@ -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
#

View File

@ -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)

View File

@ -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

View File

@ -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):

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -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"

View File

@ -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">&mdash;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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">&mdash;</span>
{% endif %}
{% endwith %}
</a>
{% endfor %}
</ul>
</div>
{% plugin_right_page object %}
</div>
</div>

View File

@ -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 %}

View File

@ -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()

View File

@ -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',

View File

@ -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,

View File

@ -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>

View File

@ -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)

View File

@ -387,7 +387,6 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
base_template = 'virtualization/virtualmachine.html'
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext',
weight=2000
)