diff --git a/README.md b/README.md index d946215d5df..26aa0ccfcd1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst ## Alternative Installations -* [Docker container](https://github.com/digitalocean/netbox-docker) -* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku)) -* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) +* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) +* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index bb1618e32e0..3cbc0d32cf2 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -55,7 +55,7 @@ LDAP_IGNORE_CERT_ERRORS = True ## User Authentication !!! info - When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. + When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. ```python from django_auth_ldap.config import LDAPSearch @@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = { # User Groups for Permissions !!! Info - When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE. + When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. ```python from django_auth_ldap.config import LDAPSearch, GroupOfNamesType diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 820477e24e3..f0b0f6d52ad 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -17,6 +17,7 @@ ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) +from virtualization.models import Cluster from .formfields import MACAddressFormField from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort, @@ -900,11 +901,20 @@ class DeviceCSVForm(BaseDeviceCSVForm): required=False, help_text='Mounted rack face' ) + cluster = forms.ModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + required=False, + help_text='Virtualization cluster', + error_messages={ + 'invalid_choice': 'Invalid cluster name.', + } + ) class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', + 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', ] def clean(self): @@ -940,11 +950,19 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): device_bay_name = forms.CharField( help_text='Name of device bay', ) + cluster = forms.ModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + help_text='Virtualization cluster', + error_messages={ + 'invalid_choice': 'Invalid cluster name.', + } + ) class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay_name', + 'parent', 'device_bay_name', 'cluster', ] def clean(self): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 387af4b61fc..13710c310f4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -34,32 +34,6 @@ ) -EXPANSION_PATTERN = '\[(\d+-\d+)\]' - - -def xstr(s): - """ - Replace None with an empty string (for CSV export) - """ - return '' if s is None else str(s) - - -def expand_pattern(string): - """ - Expand a numeric pattern into a list of strings. Examples: - 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] - 'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] - """ - lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) - x, y = pattern.split('-') - for i in range(int(x), int(y) + 1): - if remnant: - for string in expand_pattern(remnant): - yield "{0}{1}{2}".format(lead, i, string) - else: - yield "{0}{1}".format(lead, i) - - class BulkDisconnectView(View): """ An extendable view for disconnection console/power/interface components in bulk. diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5181e88e961..1e7a7316644 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -274,6 +274,7 @@ def render(self, img_format='png'): # Construct the graph graph = graphviz.Graph() graph.graph_attr['ranksep'] = '1' + seen = set() for i, device_set in enumerate(self.device_sets): subgraph = graphviz.Graph(name='sg{}'.format(i)) @@ -288,6 +289,9 @@ def render(self, img_format='png'): devices = [] for query in device_set.strip(';').split(';'): # Split regexes on semicolons devices += Device.objects.filter(name__regex=query).select_related('device_role') + # Remove duplicate devices + devices = [d for d in devices if d.id not in seen] + seen.update([d.id for d in devices]) for d in devices: bg_color = '#{}'.format(d.device_role.color) fg_color = '#{}'.format(foreground_color(d.device_role.color)) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index a5b64fca609..36394dad26b 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -240,12 +240,22 @@ class Meta: # IP addresses # +class IPAddressInterfaceSerializer(InterfaceSerializer): + virtual_machine = NestedVirtualMachineSerializer() + + class Meta(InterfaceSerializer.Meta): + fields = [ + 'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', + 'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination', + ] + + class IPAddressSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES) - interface = InterfaceSerializer() + interface = IPAddressInterfaceSerializer() class Meta: model = IPAddress @@ -262,6 +272,7 @@ class Meta: model = IPAddress fields = ['id', 'url', 'family', 'address'] + IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dabb518ae26..b615b470fd9 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -151,7 +151,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = IPAddress.objects.select_related( 'vrf__tenant', 'tenant', 'nat_inside' ).prefetch_related( - 'interface__device' + 'interface__device', 'interface__virtual_machine' ) serializer_class = serializers.IPAddressSerializer write_serializer_class = serializers.WritableIPAddressSerializer diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 791ba1b1ab7..73a522d63b2 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -440,7 +440,7 @@ def to_csv(self): self.get_status_display(), self.get_role_display(), self.device.identifier if self.device else None, - self.virtual_machine.name if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, self.interface.name if self.interface else None, is_primary, self.description, @@ -452,6 +452,12 @@ def device(self): return self.interface.device return None + @property + def virtual_machine(self): + if self.interface: + return self.interface.virtual_machine + return None + def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index 72a3ab8de33..0521f2d2ff7 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -30,6 +30,10 @@ ('Tenancy', ( ('tenant', 'Tenants'), )), + ('Virtualization', ( + ('cluster', 'Clusters'), + ('virtualmachine', 'Virtual machines'), + )), ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e0f83964e93..934da12809a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.2.1' +VERSION = '2.2.2' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 5ec81cb25f1..f41ff53c22f 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -27,7 +27,7 @@ from tenancy.tables import TenantTable from virtualization.filters import ClusterFilter, VirtualMachineFilter from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable +from virtualization.tables import ClusterTable, VirtualMachineDetailTable from .forms import SearchForm @@ -126,9 +126,11 @@ 'url': 'virtualization:cluster_list', }), ('virtualmachine', { - 'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'), + 'queryset': VirtualMachine.objects.select_related( + 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', + ), 'filter': VirtualMachineFilter, - 'table': VirtualMachineTable, + 'table': VirtualMachineDetailTable, 'url': 'virtualization:virtualmachine_list', }), )) diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 2029ea5b8e3..0a792e0c4f7 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -59,6 +59,7 @@