Closes #4755: Enable creation of rack reservations directly from navigation menu

This commit is contained in:
Jeremy Stretch 2020-06-12 15:11:27 -04:00
parent 9fd36279ab
commit 9fc4a4f24a
7 changed files with 52 additions and 59 deletions

View File

@ -5,6 +5,7 @@
### Enhancements ### Enhancements
* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI * [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
### Bug Fixes ### Bug Fixes

View File

@ -21,10 +21,10 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup, VirtualMachine from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@ -729,21 +729,32 @@ class RackElevationFilterForm(RackFilterForm):
# #
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
rack = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=forms.HiddenInput() widget=APISelect(
) filter_for={
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain 'rack_group': 'site_id',
# the multi-line <select> widget for easy selection of multiple rack units. 'rack': 'site_id',
units = SimpleArrayField(
base_field=forms.IntegerField(),
widget=ArrayFieldSelectMultiple(
attrs={
'size': 10,
} }
) )
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
'rack': 'group_id'
}
)
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all()
)
units = NumericArrayField(
base_field=forms.IntegerField(),
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
)
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=User.objects.order_by(
'username' 'username'
@ -757,23 +768,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rack unit choices
if hasattr(self.instance, 'rack'):
self.fields['units'].widget.choices = self._get_unit_choices()
def _get_unit_choices(self):
rack = self.instance.rack
reserved_units = []
for resv in rack.reservations.exclude(pk=self.instance.pk):
for u in resv.units:
reserved_units.append(u)
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
return unit_choices
class RackReservationCSVForm(CSVModelForm): class RackReservationCSVForm(CSVModelForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(

View File

@ -198,7 +198,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
'units': [10, 11, 12], 'units': "10,11,12",
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'Rack reservation', 'description': 'Rack reservation',

View File

@ -3,19 +3,21 @@
{% block form %} {% block form %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div> <div class="panel-heading"><strong>Rack Reservation</strong></div>
<div class="panel-body"> <div class="panel-body">
<div class="form-group"> {% render_field form.site %}
<label class="col-md-3 control-label">Rack</label> {% render_field form.rack_group %}
<div class="col-md-9"> {% render_field form.rack %}
<p class="form-control-static">{{ obj.rack }}</p>
</div>
</div>
{% render_field form.units %} {% render_field form.units %}
{% render_field form.user %} {% render_field form.user %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.description %} {% render_field form.description %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenant Assignment</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -70,6 +70,7 @@
<li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}> <li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
{% if perms.dcim.add_rackreservation %} {% if perms.dcim.add_rackreservation %}
<div class="buttons pull-right"> <div class="buttons pull-right">
<a href="{% url 'dcim:rackreservation_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a> <a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div> </div>
{% endif %} {% endif %}

View File

@ -7,6 +7,7 @@ import django_filters
import yaml import yaml
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.forms import SimpleArrayField
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count from django.db.models import Count
@ -243,24 +244,11 @@ class ContentTypeSelect(StaticSelect2):
option_template_name = 'widgets/select_contenttype.html' option_template_name = 'widgets/select_contenttype.html'
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): class NumericArrayField(SimpleArrayField):
"""
MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
"""
def __init__(self, *args, **kwargs):
self.delimiter = kwargs.pop('delimiter', ',')
super().__init__(*args, **kwargs)
def optgroups(self, name, value, attrs=None): def to_python(self, value):
# Split the delimited string of values into a list value = ','.join([str(n) for n in parse_numeric_range(value)])
if value: return super().to_python(value)
value = value[0].split(self.delimiter)
return super().optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name):
# Condense the list of selected choices into a delimited string
data = super().value_from_datadict(data, files, name)
return self.delimiter.join(data)
class APISelect(SelectWithDisabled): class APISelect(SelectWithDisabled):

View File

@ -1,5 +1,6 @@
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings from django.test import Client, TestCase as _TestCase, override_settings
@ -92,6 +93,12 @@ class TestCase(_TestCase):
if type(value) is IPNetwork: if type(value) is IPNetwork:
model_dict[key] = str(value) model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
# Omit any dictionary keys which are not instance attributes # Omit any dictionary keys which are not instance attributes
relevant_data = { relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k) k: v for k, v in data.items() if hasattr(instance, k)