mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
commit
80c8c4c4b2
20
CHANGELOG.md
20
CHANGELOG.md
@ -1,3 +1,23 @@
|
||||
v2.6.1 (2019-06-25)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3154](https://github.com/digitalocean/netbox/issues/3154) - Add `virtual_chassis_member` device filter
|
||||
* [#3277](https://github.com/digitalocean/netbox/issues/3277) - Add cable trace buttons for console and power ports
|
||||
* [#3281](https://github.com/digitalocean/netbox/issues/3281) - Hide custom links which render as empty text
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#3229](https://github.com/digitalocean/netbox/issues/3229) - Limit rack group selection by parent site on racks list
|
||||
* [#3269](https://github.com/digitalocean/netbox/issues/3269) - Raise validation error when specifying non-existent cable terminations
|
||||
* [#3275](https://github.com/digitalocean/netbox/issues/3275) - Fix error when adding power outlets to a device type
|
||||
* [#3279](https://github.com/digitalocean/netbox/issues/3279) - Reset the PostgreSQL sequence for Tag and TaggedItem IDs
|
||||
* [#3283](https://github.com/digitalocean/netbox/issues/3283) - Fix rack group assignment on PowerFeed CSV import
|
||||
* [#3290](https://github.com/digitalocean/netbox/issues/3290) - Fix server error when viewing cascaded PDUs
|
||||
* [#3292](https://github.com/digitalocean/netbox/issues/3292) - Ignore empty URL query parameters
|
||||
|
||||
---
|
||||
|
||||
v2.6.0 (2019-06-20)
|
||||
|
||||
## New Features
|
||||
|
@ -527,6 +527,10 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
label='Virtual chassis (ID)',
|
||||
)
|
||||
virtual_chassis_member = django_filters.BooleanFilter(
|
||||
method='_virtual_chassis_member',
|
||||
label='Is a virtual chassis member'
|
||||
)
|
||||
console_ports = django_filters.BooleanFilter(
|
||||
method='_console_ports',
|
||||
label='Has console ports',
|
||||
@ -590,6 +594,9 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
|
||||
def _virtual_chassis_member(self, queryset, name, value):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
|
||||
def _console_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleports__isnull=value)
|
||||
|
||||
|
@ -601,12 +601,18 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'group_id': 'site'
|
||||
}
|
||||
)
|
||||
)
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.select_related('site'),
|
||||
group_id = ChainedModelChoiceField(
|
||||
label='Rack group',
|
||||
null_label='-- None --',
|
||||
queryset=RackGroup.objects.select_related('site'),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/rack-groups/",
|
||||
null_option=True,
|
||||
@ -951,10 +957,6 @@ class PowerPortTemplateCreateForm(ComponentForm):
|
||||
|
||||
|
||||
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
@ -965,6 +967,21 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
feed_leg = forms.ChoiceField(
|
||||
choices=add_blank_choice(POWERFEED_LEG_CHOICES),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -975,12 +992,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletTemplateCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
@ -1739,6 +1750,13 @@ class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
virtual_chassis_member = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Virtual chassis member',
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
console_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has console ports',
|
||||
@ -3580,7 +3598,7 @@ class PowerFeedCSVForm(forms.ModelForm):
|
||||
# Validate rack
|
||||
if rack_name:
|
||||
try:
|
||||
self.instance.rack = Rack.objects.get(site=site, rack_group=rack_group, name=rack_name)
|
||||
self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name)
|
||||
except Rack.DoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
"Rack {} not found in site {}, group {}".format(rack_name, site, rack_group)
|
||||
|
@ -2747,55 +2747,69 @@ class Cable(ChangeLoggedModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.termination_a and self.termination_b:
|
||||
# Validate that termination A exists
|
||||
try:
|
||||
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
# Validate that termination B exists
|
||||
try:
|
||||
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
||||
if (
|
||||
(
|
||||
isinstance(endpoint_a, Interface) and
|
||||
endpoint_a.type == IFACE_TYPE_VIRTUAL
|
||||
) or
|
||||
(
|
||||
isinstance(endpoint_b, Interface) and
|
||||
endpoint_b.type == IFACE_TYPE_VIRTUAL
|
||||
)
|
||||
):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
||||
if (
|
||||
(
|
||||
isinstance(endpoint_a, Interface) and
|
||||
endpoint_a.type == IFACE_TYPE_VIRTUAL
|
||||
) or
|
||||
(
|
||||
isinstance(endpoint_b, Interface) and
|
||||
endpoint_b.type == IFACE_TYPE_VIRTUAL
|
||||
)
|
||||
):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and self.length_unit is None:
|
||||
|
@ -433,7 +433,7 @@ class PowerOutletTemplateTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerOutletTemplate
|
||||
fields = ('pk', 'name')
|
||||
fields = ('pk', 'name', 'power_port', 'feed_leg')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
|
@ -87,7 +87,8 @@ class CustomLinkForm(forms.ModelForm):
|
||||
model = CustomLink
|
||||
exclude = []
|
||||
help_texts = {
|
||||
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>.',
|
||||
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
|
||||
'which render as empty text will not be displayed.',
|
||||
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
|
||||
}
|
||||
|
||||
|
14
netbox/extras/migrations/0023_fix_tag_sequences.py
Normal file
14
netbox/extras/migrations/0023_fix_tag_sequences.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0022_custom_links'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Update the last_value for tag Tag and TaggedItem ID sequences
|
||||
migrations.RunSQL("SELECT setval('extras_tag_id_seq', (SELECT id FROM extras_tag ORDER BY id DESC LIMIT 1) + 1)"),
|
||||
migrations.RunSQL("SELECT setval('extras_taggeditem_id_seq', (SELECT id FROM extras_taggeditem ORDER BY id DESC LIMIT 1) + 1)"),
|
||||
]
|
@ -15,7 +15,8 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
|
||||
'<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \
|
||||
'{} <span class="caret"></span>\n' \
|
||||
'</button>\n' \
|
||||
'<ul class="dropdown-menu pull-right">\n'
|
||||
'<ul class="dropdown-menu pull-right">\n' \
|
||||
'{}</ul></div>'
|
||||
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
|
||||
|
||||
|
||||
@ -35,32 +36,40 @@ def custom_links(obj):
|
||||
template_code = ''
|
||||
group_names = OrderedDict()
|
||||
|
||||
# Organize custom links by group
|
||||
for cl in custom_links:
|
||||
|
||||
# Organize custom links by group
|
||||
if cl.group_name and cl.group_name in group_names:
|
||||
group_names[cl.group_name].append(cl)
|
||||
elif cl.group_name:
|
||||
group_names[cl.group_name] = [cl]
|
||||
|
||||
# Add non-grouped links
|
||||
for cl in custom_links:
|
||||
if not cl.group_name:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
cl.url, link_target, cl.button_class, cl.text
|
||||
)
|
||||
# Add non-grouped links
|
||||
else:
|
||||
text_rendered = Environment().from_string(source=cl.text).render(**context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
cl.url, link_target, cl.button_class, text_rendered
|
||||
)
|
||||
|
||||
# Add grouped links to template
|
||||
for group, links in group_names.items():
|
||||
template_code += GROUP_BUTTON.format(
|
||||
links[0].button_class, group
|
||||
)
|
||||
|
||||
links_rendered = []
|
||||
|
||||
for cl in links:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += GROUP_LINK.format(
|
||||
cl.url, link_target, cl.text
|
||||
text_rendered = Environment().from_string(source=cl.text).render(**context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(cl.url, link_target, cl.text)
|
||||
)
|
||||
|
||||
if links_rendered:
|
||||
template_code += GROUP_BUTTON.format(
|
||||
links[0].button_class, group, ''.join(links_rendered)
|
||||
)
|
||||
template_code += '</ul>\n</div>\n'
|
||||
|
||||
# Render template
|
||||
rendered = Environment().from_string(source=template_code).render(**context)
|
||||
|
@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6.0'
|
||||
VERSION = '2.6.1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -346,7 +346,7 @@ else:
|
||||
REDIS_CACHE_CON_STRING = 'redis://'
|
||||
|
||||
if REDIS_PASSWORD:
|
||||
REDIS_CACHE_CON_STRING = '{}{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
|
||||
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
|
||||
|
||||
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE)
|
||||
|
||||
|
@ -359,7 +359,7 @@
|
||||
<td>{{ pp }}</td>
|
||||
<td>{{ utilization.outlet_count }}</td>
|
||||
<td>{{ utilization.allocated }}VA</td>
|
||||
{% if powerfeed %}
|
||||
{% if powerfeed.available_power %}
|
||||
<td>{{ powerfeed.available_power }}VA</td>
|
||||
<td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td>
|
||||
{% else %}
|
||||
|
@ -15,6 +15,9 @@
|
||||
<td>
|
||||
{% if cp.cable %}
|
||||
<a href="{{ cp.cable.get_absolute_url }}">{{ cp.cable }}</a>
|
||||
<a href="{% url 'dcim:consoleport_trace' pk=cp.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
|
@ -23,6 +23,9 @@
|
||||
<td>
|
||||
{% if pp.cable %}
|
||||
<a href="{{ pp.cable.get_absolute_url }}">{{ pp.cable }}</a>
|
||||
<a href="{% url 'dcim:powerport_trace' pk=pp.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
|
@ -9,7 +9,7 @@ from extras.models import Tag
|
||||
def multivalue_field_factory(field_class):
|
||||
"""
|
||||
Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple
|
||||
filter values while maintaining the field's built-in vlaidation. Example: GET /api/dcim/devices/?name=foo&name=bar
|
||||
filter values while maintaining the field's built-in validation. Example: GET /api/dcim/devices/?name=foo&name=bar
|
||||
"""
|
||||
class NewField(field_class):
|
||||
widget = forms.SelectMultiple
|
||||
@ -17,7 +17,10 @@ def multivalue_field_factory(field_class):
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return []
|
||||
return [super(field_class, self).to_python(v) for v in value]
|
||||
return [
|
||||
# Only append non-empty values (this avoids e.g. trying to cast '' as an integer)
|
||||
super(field_class, self).to_python(v) for v in value if v
|
||||
]
|
||||
|
||||
return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user