Merge branch 'feature' into feature-2434

This commit is contained in:
checktheroads 2021-05-14 00:12:19 -07:00
commit 234475effe
21 changed files with 190 additions and 66 deletions

View File

@ -33,6 +33,16 @@ The `as_attachment` attribute of an export template controls its behavior when r
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## REST API Integration
When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/optional-settings.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example:
```
GET /api/dcim/sites/?export=MyTemplateName
```
Note that the body of the response will contain only the rendered export template content, as opposed to a JSON object or list.
## Example
Here's an example device export template that will generate a simple Nagios configuration from a list of devices.

View File

@ -2,22 +2,38 @@
## v2.12-beta1 (FUTURE)
### Enhancements
* [#3665](https://github.com/netbox-community/netbox/issues/3665) - Enable rendering export templates via REST API
* [#4609](https://github.com/netbox-community/netbox/issues/4609) - Allow marking prefixes as fully utilized
* [#5806](https://github.com/netbox-community/netbox/issues/5806) - Add kilometer and mile as choices for cable length unit
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
### Other Changes
* [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
* [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
* [#6338](https://github.com/netbox-community/netbox/issues/6338) - Decimal fields are no longer coerced to strings in REST API
### REST API Changes
* dcim.Cable
* `length` is now a decimal value
* dcim.Device
* Removed the `display_name` attribute (use `display` instead)
* dcim.DeviceType
* Removed the `display_name` attribute (use `display` instead)
* dcim.Rack
* Removed the `display_name` attribute (use `display` instead)
* dcim.Site
* `latitude` and `longitude` are now decimal fields rather than strings
* extras.ContentType
* Removed the `display_name` attribute (use `display` instead)
* ipam.Prefix
* Added the `mark_utilized` boolean field
* ipam.VLAN
* Removed the `display_name` attribute (use `display` instead)
* ipam.VRF
* Removed the `display_name` attribute (use `display` instead)
* virtualization.VirtualMachine
* `vcpus` is now a decimal field rather than a string

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 class="progress">
<div
aria-valuemin="0"
role="progressbar"
aria-valuemax="100"
class="progress-bar bg-danger"
aria-valuenow="{{ utilization }}"
style="width: {{ utilization }}%;"
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>
</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):