mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'develop' into feature
This commit is contained in:
@@ -116,6 +116,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate data backend type
|
||||
if self.type and self.type not in registry['data_backends']:
|
||||
|
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -85,6 +86,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self.data_file.write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure that the file root and path make a unique pair
|
||||
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(
|
||||
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete file from disk
|
||||
try:
|
||||
|
@@ -244,7 +244,7 @@ class Job(models.Model):
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
@@ -283,7 +283,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls()
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
|
@@ -121,8 +121,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(extra_choices__contains=value)
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def filter_by_choice(self, queryset, name, value):
|
||||
|
@@ -1093,7 +1093,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name
|
||||
name=report.class_name
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
|
@@ -115,7 +115,7 @@ def flush_webhooks(queue):
|
||||
event=data['event'],
|
||||
data=data['data'],
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=data['username'],
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
|
@@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
order_by=tables.A('asn'),
|
||||
verbose_name=_('ASDOT')
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
|
@@ -27,7 +27,7 @@ from netbox.plugins import PluginConfig
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.6-dev'
|
||||
VERSION = '3.6.7-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
if instance.pk and hasattr(instance, 'snapshot'):
|
||||
instance.snapshot()
|
||||
|
||||
# Instantiate the model form for the object
|
||||
model_form_kwargs = {
|
||||
'data': record,
|
||||
|
@@ -5,6 +5,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -15,16 +16,7 @@
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{% for region in object.site.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.site.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.site.region %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
@@ -32,16 +24,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
|
@@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -15,26 +16,18 @@
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
{{ location|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.location|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% nested_tree object.location %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Facility ID" %}</th>
|
||||
|
@@ -4,6 +4,7 @@
|
||||
{% load static %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@@ -20,25 +21,24 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% with rack=object.rack %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if rack.site.region %}
|
||||
{{ rack.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ rack.site|linkify }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ rack|linkify }}</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.rack.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.rack.site|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Location" %}</th>
|
||||
<td>{{ object.rack.location|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Rack" %}</th>
|
||||
<td>{{ object.rack|linkify }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -3,6 +3,7 @@
|
||||
{% load plugins %}
|
||||
{% load tz %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
@@ -29,27 +30,13 @@
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% if object.region %}
|
||||
{% for region in object.region.get_ancestors %}
|
||||
{{ region|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.region|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.region %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
<td>
|
||||
{% if object.group %}
|
||||
{% for group in object.group.get_ancestors %}
|
||||
{{ group|linkify }} /
|
||||
{% endfor %}
|
||||
{{ object.group|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
{% nested_tree object.group %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@@ -3,6 +3,7 @@
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -44,18 +45,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN" %}</th>
|
||||
|
@@ -3,6 +3,7 @@
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
{% load mptt %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
@@ -13,18 +14,17 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>
|
||||
{% if object.site %}
|
||||
{% if object.site.region %}
|
||||
{{ object.site.region|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.site|linkify }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
|
@@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ContentType
|
||||
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import CustomFieldsMixin, TagsMixin
|
||||
from netbox.models.features import CustomFieldsMixin, ExportTemplatesMixin, TagsMixin
|
||||
from tenancy.choices import *
|
||||
|
||||
__all__ = (
|
||||
@@ -110,7 +110,7 @@ class Contact(PrimaryModel):
|
||||
return reverse('tenancy:contact', args=[self.pk])
|
||||
|
||||
|
||||
class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
|
||||
class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
on_delete=models.CASCADE
|
||||
|
@@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer):
|
||||
|
||||
return user
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Ensure proper updated password hash generation.
|
||||
"""
|
||||
password = validated_data.pop('password', None)
|
||||
if password is not None:
|
||||
instance.set_password(password)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_display(self, obj):
|
||||
if full_name := obj.get_full_name():
|
||||
|
@@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
User.objects.bulk_create(users)
|
||||
|
||||
def test_that_password_is_changed(self):
|
||||
"""
|
||||
Test that password is changed
|
||||
"""
|
||||
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['change']
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
|
||||
|
||||
user_credentials = {
|
||||
'username': 'user1',
|
||||
'password': 'abc123',
|
||||
}
|
||||
user = User.objects.create_user(**user_credentials)
|
||||
|
||||
data = {
|
||||
'password': 'newpassword'
|
||||
}
|
||||
url = reverse('users-api:user-detail', kwargs={'pk': user.id})
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
updated_user = User.objects.get(id=user.id)
|
||||
|
||||
self.assertTrue(updated_user.check_password(data['password']))
|
||||
|
||||
|
||||
class GroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Group
|
||||
|
@@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10):
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
values.extend(range(begin, end))
|
||||
return list(set(values))
|
||||
return sorted(set(values))
|
||||
|
||||
|
||||
def parse_alphanumeric_range(string):
|
||||
|
20
netbox/utilities/templatetags/mptt.py
Normal file
20
netbox/utilities/templatetags/mptt.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def nested_tree(obj):
|
||||
"""
|
||||
Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
|
||||
"""
|
||||
if not obj:
|
||||
return mark_safe('—')
|
||||
|
||||
nodes = obj.get_ancestors(include_self=True)
|
||||
return mark_safe(
|
||||
' / '.join(
|
||||
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
|
||||
)
|
||||
)
|
@@ -296,9 +296,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
|
||||
if site is None:
|
||||
site = interface.virtual_machine.cluster.site
|
||||
elif interface.virtual_machine.cluster.site is not site:
|
||||
site = vm_site
|
||||
elif vm_site is not site:
|
||||
site = None
|
||||
break
|
||||
|
||||
|
Reference in New Issue
Block a user