1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'feature' of github.com:netbox-community/netbox into feature

This commit is contained in:
checktheroads
2021-05-14 00:10:06 -07:00
21 changed files with 190 additions and 66 deletions

View File

@@ -1064,14 +1064,21 @@ class CableStatusChoices(ChoiceSet):
class CableLengthUnitChoices(ChoiceSet):
# Metric
UNIT_KILOMETER = 'km'
UNIT_METER = 'm'
UNIT_CENTIMETER = 'cm'
# Imperial
UNIT_MILE = 'mi'
UNIT_FOOT = 'ft'
UNIT_INCH = 'in'
CHOICES = (
(UNIT_KILOMETER, 'Kilometers'),
(UNIT_METER, 'Meters'),
(UNIT_CENTIMETER, 'Centimeters'),
(UNIT_MILE, 'Miles'),
(UNIT_FOOT, 'Feet'),
(UNIT_INCH, 'Inches'),
)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0131_consoleport_speed'),
]
operations = [
migrations.AlterField(
model_name='cable',
name='length',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
]

View File

@@ -74,7 +74,9 @@ class Cable(PrimaryModel):
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
length = models.DecimalField(
max_digits=8,
decimal_places=2,
blank=True,
null=True
)

View File

@@ -300,13 +300,12 @@ class ExportTemplate(BigIDModel):
# Build the response
response = HttpResponse(output, content_type=mime_type)
filename = 'netbox_{}{}'.format(
queryset.model._meta.verbose_name_plural,
'.{}'.format(self.file_extension) if self.file_extension else ''
)
if self.as_attachment:
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else ''
filename = f'netbox_{basename}{extension}'
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response

View File

@@ -202,7 +202,7 @@ class PrefixSerializer(PrimaryModelSerializer):
model = Prefix
fields = [
'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
'mark_utilized', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']

View File

@@ -304,7 +304,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta:
model = Prefix
fields = ['id', 'is_pool']
fields = ['id', 'is_pool', 'mark_utilized']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -454,11 +454,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta:
model = Prefix
fields = [
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant',
'tags',
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
'tenant_group', 'tenant', 'tags',
]
fieldsets = (
('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')),
('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -582,6 +582,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
widget=BulkEditNullBooleanSelect(),
label='Is a pool'
)
mark_utilized = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label='Treat as 100% utilized'
)
description = forms.CharField(
max_length=100,
required=False
@@ -597,7 +602,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
model = Prefix
field_order = [
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id',
'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool',
'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', 'mark_utilized',
]
mask_length__lte = forms.IntegerField(
widget=forms.HiddenInput()
@@ -675,6 +680,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Marked as 100% utilized'),
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0046_set_vlangroup_scope_types'),
]
operations = [
migrations.AddField(
model_name='prefix',
name='mark_utilized',
field=models.BooleanField(default=False),
),
]

View File

@@ -288,6 +288,10 @@ class Prefix(PrimaryModel):
default=False,
help_text='All IP addresses within this prefix are considered usable'
)
mark_utilized = models.BooleanField(
default=False,
help_text="Treat as 100% utilized"
)
description = models.CharField(
max_length=200,
blank=True
@@ -296,10 +300,11 @@ class Prefix(PrimaryModel):
objects = PrefixQuerySet.as_manager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
'description',
]
clone_fields = [
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
]
class Meta:
@@ -364,6 +369,7 @@ class Prefix(PrimaryModel):
self.get_status_display(),
self.role.name if self.role else None,
self.is_pool,
self.mark_utilized,
self.description,
)
@@ -422,6 +428,9 @@ class Prefix(PrimaryModel):
"""
Return all available IPs within this prefix as an IPSet.
"""
if self.mark_utilized:
return list()
prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
available_ips = prefix - child_ips
@@ -461,6 +470,9 @@ class Prefix(PrimaryModel):
Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of
"container", calculate utilization based on child prefixes. For all others, count child IP addresses.
"""
if self.mark_utilized:
return 100
if self.status == PrefixStatusChoices.STATUS_CONTAINER:
queryset = Prefix.objects.filter(
prefix__net_contained=str(self.prefix),

View File

@@ -256,6 +256,21 @@ class RoleTable(BaseTable):
# Prefixes
#
class PrefixUtilizationColumn(UtilizationColumn):
"""
Extend UtilizationColumn to allow disabling the warning & danger thresholds for prefixes
marked as fully utilized.
"""
template_code = """
{% load helpers %}
{% if record.pk and record.mark_utilized %}
{% utilization_graph value warning_threshold=0 danger_threshold=0 %}
{% elif record.pk %}
{% utilization_graph value %}
{% endif %}
"""
class PrefixTable(BaseTable):
pk = ToggleColumn()
prefix = tables.TemplateColumn(
@@ -283,11 +298,15 @@ class PrefixTable(BaseTable):
is_pool = BooleanColumn(
verbose_name='Pool'
)
mark_utilized = BooleanColumn(
verbose_name='Marked Utilized'
)
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'prefix', 'status', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description',
'pk', 'prefix', 'status', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'mark_utilized',
'description',
)
default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
@@ -296,7 +315,7 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable):
utilization = UtilizationColumn(
utilization = PrefixUtilizationColumn(
accessor='get_utilization',
orderable=False
)
@@ -308,7 +327,7 @@ class PrefixDetailTable(PrefixTable):
class Meta(PrefixTable.Meta):
fields = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'is_pool',
'description', 'tags',
'mark_utilized', 'description', 'tags',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',

View File

@@ -389,11 +389,11 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
prefixes = (
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
@@ -417,6 +417,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'is_pool': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_mark_utilized(self):
params = {'mark_utilized': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'mark_utilized': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_within(self):
params = {'within': '10.0.0.0/16'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@@ -5,9 +5,11 @@ from collections import OrderedDict
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.response import Response
@@ -16,6 +18,7 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
from rq.worker import Worker
from extras.models import ExportTemplate
from netbox.api import BulkOperationSerializer
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import SerializerNotFound
@@ -222,6 +225,18 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
queryset = self.filter_queryset(self.get_queryset())
return et.render_to_response(queryset)
return super().list(request, *args, **kwargs)
def perform_create(self, serializer):
model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')

View File

@@ -464,6 +464,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null'
REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0] # Use major.minor as API version
REST_FRAMEWORK = {
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
'COERCE_DECIMAL_TO_STRING': False,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'netbox.api.authentication.TokenAuthentication',

View File

@@ -60,7 +60,7 @@
<th scope="row">Length</th>
<td>
{% if object.length %}
{{ object.length }} {{ object.get_length_unit_display }}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}

View File

@@ -10,7 +10,7 @@
<span class="badge bg-secondary">{{ cable.get_type_display|default:"" }}</span>
{% endif %}
{% if cable.length %}
({{ cable.length }} {{ cable.get_length_unit_display }})<br />
({{ cable.length|floatformat }} {{ cable.get_length_unit_display }})<br />
{% endif %}
<span class="badge bg-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span><br />
{% for tag in cable.tags.all %}

View File

@@ -7,10 +7,10 @@
<div class="col col-md-5">
<div class="card">
<h5 class="card-header">
Prefix
Prefix
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<table class="table table-hover attr-table">
<tr>
<td colspan="2">
<span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
@@ -20,7 +20,6 @@
<span class="badge bg-info">Not a Pool</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Family</th>
@@ -101,9 +100,16 @@
</tr>
<tr>
<th scope="row">Utilization</th>
<td>{% utilization_graph object.get_utilization %}</td>
<td>
{% if object.mark_utilized %}
{% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
<small>(Marked fully utilized)</small>
{% else %}
{% utilization_graph object.get_utilization %}
{% endif %}
</td>
</tr>
</table>
</table>
</div>
</div>
{% include 'inc/custom_fields_panel.html' %}

View File

@@ -1,42 +1,18 @@
{% if utilization == 0 %}
<div class="progress align-items-center justify-content-center">
<div class="progress align-items-center justify-content-center">
<span class="w-100 text-center">{{ utilization }}%</span>
</div>
</div>
{% else %}
<div class="progress">
{% if utilization >= danger_threshold %}
<div
aria-valuemin="0"
role="progressbar"
aria-valuemax="100"
class="progress-bar bg-danger"
aria-valuenow="{{ utilization }}"
style="width: {{ utilization }}%;"
<div class="progress">
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
class="progress-bar {{ bar_class }}"
style="min-width: 8%; width: {{ utilization }}%;"
>
{{ utilization }}%
{{ utilization }}%
</div>
{% elif utilization >= warning_threshold %}
<div
aria-valuemin="0"
role="progressbar"
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
style="width: {{ utilization }}%;"
class="progress-bar bg-warning"
>
{{ utilization }}%
</div>
{% else %}
<div
aria-valuemin="0"
role="progressbar"
aria-valuemax="100"
class="progress-bar bg-success"
aria-valuenow="{{ utilization }}"
style="min-width: 8%;width: {{ utilization }}%;"
>
{{ utilization }}%
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -276,10 +276,17 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
"""
Display a horizontal bar graph indicating a percentage of utilization.
"""
if danger_threshold and utilization >= danger_threshold:
bar_class = 'bg-danger'
elif warning_threshold and utilization >= warning_threshold:
bar_class = 'bg-warning'
elif warning_threshold or danger_threshold:
bar_class = 'bg-success'
else:
bar_class = 'bg-default'
return {
'utilization': utilization,
'warning_threshold': warning_threshold,
'danger_threshold': danger_threshold,
'bar_class': bar_class,
}

View File

@@ -198,15 +198,19 @@ def to_meters(length, unit):
"Unknown unit {}. Must be one of the following: {}".format(unit, ', '.join(valid_units))
)
if unit == CableLengthUnitChoices.UNIT_KILOMETER:
return length * 1000
if unit == CableLengthUnitChoices.UNIT_METER:
return length
if unit == CableLengthUnitChoices.UNIT_CENTIMETER:
return length / 100
if unit == CableLengthUnitChoices.UNIT_MILE:
return length * 1609.344
if unit == CableLengthUnitChoices.UNIT_FOOT:
return length * 0.3048
if unit == CableLengthUnitChoices.UNIT_INCH:
return length * 0.3048 * 12
raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
def render_jinja2(template_code, context):