Commit 60b6d979 by Czémán Arnold

network, settings: add basic network topology editor, eleminate pipeline cache, add ES6 compiler

parent da10884e
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
"favico.js": "~0.3.5", "favico.js": "~0.3.5",
"datatables": "~1.10.4", "datatables": "~1.10.4",
"chart.js": "2.3.0", "chart.js": "2.3.0",
"clipboard": "~1.6.1" "clipboard": "~1.6.1",
"jsPlumb": "2.5.7"
} }
} }
...@@ -165,16 +165,19 @@ p = normpath(join(SITE_ROOT, '../../site-circle/static')) ...@@ -165,16 +165,19 @@ p = normpath(join(SITE_ROOT, '../../site-circle/static'))
if exists(p): if exists(p):
STATICFILES_DIRS.append(p) STATICFILES_DIRS.append(p)
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage'
PIPELINE = { PIPELINE = {
'COMPILERS' : ('pipeline.compilers.less.LessCompiler',), 'COMPILERS' : ('pipeline.compilers.less.LessCompiler',
'pipeline.compilers.es6.ES6Compiler', ),
'LESS_ARGUMENTS': u'--include-path={}'.format(':'.join(STATICFILES_DIRS)), 'LESS_ARGUMENTS': u'--include-path={}'.format(':'.join(STATICFILES_DIRS)),
'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor', 'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
'BABEL_ARGUMENTS': u'--presets env',
'JS_COMPRESSOR': None, 'JS_COMPRESSOR': None,
'DISABLE_WRAPPER': True, 'DISABLE_WRAPPER': True,
'STYLESHEETS': { 'STYLESHEETS': {
"all": {"source_filenames": ( "all": {
"source_filenames": (
"compile_bootstrap.less", "compile_bootstrap.less",
"bootstrap/dist/css/bootstrap-theme.css", "bootstrap/dist/css/bootstrap-theme.css",
"fontawesome/css/font-awesome.css", "fontawesome/css/font-awesome.css",
...@@ -187,10 +190,17 @@ PIPELINE = { ...@@ -187,10 +190,17 @@ PIPELINE = {
"autocomplete_light/select2.css", "autocomplete_light/select2.css",
), ),
"output_filename": "all.css", "output_filename": "all.css",
} },
"network-editor": {
"source_filenames": (
"network/editor.css",
),
"output_filename": "network-editor.css",
},
}, },
'JAVASCRIPT': { 'JAVASCRIPT': {
"all": {"source_filenames": ( "all": {
"source_filenames": (
# "jquery/dist/jquery.js", # included separately # "jquery/dist/jquery.js", # included separately
"bootbox/bootbox.js", "bootbox/bootbox.js",
"bootstrap/dist/js/bootstrap.js", "bootstrap/dist/js/bootstrap.js",
...@@ -203,6 +213,7 @@ PIPELINE = { ...@@ -203,6 +213,7 @@ PIPELINE = {
"autocomplete_light/autocomplete.init.js", "autocomplete_light/autocomplete.init.js",
"autocomplete_light/vendor/select2/dist/js/select2.js", "autocomplete_light/vendor/select2/dist/js/select2.js",
"autocomplete_light/select2.js", "autocomplete_light/select2.js",
"jsPlumb/dist/js/dom.jsPlumb-1.7.5-min.js",
"dashboard/dashboard.js", "dashboard/dashboard.js",
"dashboard/activity.js", "dashboard/activity.js",
"dashboard/group-details.js", "dashboard/group-details.js",
...@@ -225,7 +236,8 @@ PIPELINE = { ...@@ -225,7 +236,8 @@ PIPELINE = {
), ),
"output_filename": "all.js", "output_filename": "all.js",
}, },
"vm-detail": {"source_filenames": ( "vm-detail": {
"source_filenames": (
"clipboard/dist/clipboard.min.js", "clipboard/dist/clipboard.min.js",
"dashboard/vm-details.js", "dashboard/vm-details.js",
"no-vnc/include/util.js", "no-vnc/include/util.js",
...@@ -245,12 +257,20 @@ PIPELINE = { ...@@ -245,12 +257,20 @@ PIPELINE = {
), ),
"output_filename": "vm-detail.js", "output_filename": "vm-detail.js",
}, },
"datastore": {"source_filenames": ( "datastore": {
"source_filenames": (
"chart.js/dist/Chart.min.js", "chart.js/dist/Chart.min.js",
"dashboard/datastore-details.js" "dashboard/datastore-details.js"
), ),
"output_filename": "datastore.js", "output_filename": "datastore.js",
}, },
"network-editor": {
"source_filenames": (
"jsPlumb/dist/js/jsplumb.min.js",
"network/editor.es6",
),
"output_filename": "network-editor.js",
},
}, },
} }
......
...@@ -112,6 +112,7 @@ if DEBUG: ...@@ -112,6 +112,7 @@ if DEBUG:
PIPELINE["COMPILERS"] = ( PIPELINE["COMPILERS"] = (
'dashboard.compilers.DummyLessCompiler', 'dashboard.compilers.DummyLessCompiler',
'pipeline.compilers.es6.ES6Compiler',
) )
ADMIN_ENABLED = True ADMIN_ENABLED = True
......
/* jshint esversion: 6 */
function renderListElement(elem){
return `
<div class="unused-element"
type="${ elem.type }"
description="${ elem.description }"
id="${ elem.id }"
icon="${ elem.icon }"
name="${ elem.name }"
free_port_num="${ elem.free_port_num }">
<i class="fa ${ elem.type == 'vm' ? 'fa-desktop' : 'fa-sitemap' }"></i>
${ elem.name }
</div>`;
}
function renderBoardElement(elem){
return `
<div class="element"
name="${ elem.name }"
type="${ elem.type }"
id="${ elem.id }"
description="${ elem.description }"
icon="${ elem.icon }"
free_port_num="${ elem.free_port_num }"
ondragstart="return false;">
<i class="fa ${ elem.type == 'vm' ? 'fa-desktop' : 'fa-sitemap'}"></i>
${ elem.name }
</div>`;
}
var add_interfaces = [];
var remove_interfaces = [];
var old_connections = [];
var add_nodes = new Set();
var remove_nodes = new Set();
var old_nodes = new Set();
function convertConnection(connection) {
var con = {
source: connection.source.id,
target: connection.target.id,
equals: function(other) {
return this.source === other.source &&
this.target === other.target;
}
};
if(con.source.startsWith('net')){
var tmp = con.source;
con.source = con.target;
con.target = tmp;
}
con.source = con.source.slice(3);
con.target = con.target.slice(4);
return con;
}
function uniqueAddToList(list, value){
var hit = list.find((val) => {
return val.equals(value);
});
if(hit) return;
list.push(value);
}
function removeFromList(list, value){
var index = list.findIndex((val) => {
return val.equals(value);
});
if(index === -1) return;
list.splice(index, 1);
}
function isOldConnection(connection){
return old_connections.find((val) => {
return val.equals(connection);
});
}
function cleanListElements(list){
return list.map((value) => {
return {
source: value.source,
target: value.target
};
});
}
function addInterface(connection) {
var con = convertConnection(connection);
if(!isOldConnection(con))
uniqueAddToList(add_interfaces, con);
removeFromList(remove_interfaces, con);
}
function removeInterface(connection) {
var con = convertConnection(connection);
if(isOldConnection(con))
uniqueAddToList(remove_interfaces, con);
removeFromList(add_interfaces, con);
}
function getNodeId(node) {
return node.id;
}
function isOldNode(id) {
return old_nodes.has(id);
}
function addNode(node) {
var id = getNodeId(node);
add_nodes.add(id);
remove_nodes.delete(id);
}
function removeNode(node) {
var id = getNodeId(node);
if(isOldNode(id))
remove_nodes.add(id);
add_nodes.delete(id);
}
function checkLoopback(connection) {
return connection.target !== connection.source;
}
function checkCompatibility(connection) {
var target_type = $(connection.target).attr('type');
var source_type = $(connection.source).attr('type');
return target_type !== source_type;
}
// Before the connection is established
function beforeDrop(info) {
return checkCompatibility(info.connection);
}
function onConnect(info) {
addInterface(info.connection);
}
function onDetach(info) {
removeInterface(info.connection);
}
function FakeConnection(sourceId, targetId) {
this.source = {id: sourceId};
this.target = {id: targetId};
return this;
}
function onConnectionMoved(info) {
var fakeOrigCon = new FakeConnection(info.originalSourceId,
info.originalTargetId);
removeInterface(fakeOrigCon);
var fakeNewCon = new FakeConnection(info.newSourceId,
info.newTargetId);
addInterface(fakeNewCon);
}
function onConnectionAborted(connection) {
addInterface(connection);
}
function randInt(from, to) {
if(from > to){
var tmp = to;
to = from;
from = tmp;
}
var size = to - from;
return Math.floor((Math.random() * size) + from);
}
function convertElement(elem) {
return {
id: elem.attr('id'),
type: elem.attr('type'),
description: elem.attr('description'),
name: elem.attr('name'),
icon: elem.attr('icon'),
free_port_num: elem.attr('free_port_num')
};
}
function convertListForSaving(list) {
const getId = (id, type) =>
(type === 'vm') ? id.slice(3) : id.slice(4);
var retv = [];
list.forEach( (i, id) => {
var e=$('#' + id);
retv.push({
id: getId(e.attr('id'), e.attr('type')),
type: e.attr('type'),
x: e.css('left').replace('px', ''),
y: e.css('top').replace('px', ''),
free_port_num: e.attr('free_port_num')
});
});
return retv;
}
class SwitchButton {
constructor(id, checked) {
this.element = $('#' + id);
this.element.css('cursor', 'pointer');
this.afterChanged(() => {});
this.setClass(checked);
}
setClass(checked){
this.element.removeClass('fa');
this.element.removeClass('fa-toggle-on');
this.element.removeClass('fa-toggle-off');
this.element.addClass('fa');
this.element.addClass( checked ? 'fa-toggle-on' : 'fa-toggle-off');
}
isChecked() {
return this.element.hasClass('fa-toggle-on');
}
afterChanged(func) {
this.element.off('click');
var thiz = this;
this.element.click(function() {
thiz.setClass(!thiz.isChecked());
func();
});
}
}
jsPlumb.ready(() => {
var endpointOptions = {
anchor: 'Continuous',
isSource: true,
isTarget: true,
maxConnections: 1
};
var jsPlumbInstance = jsPlumb;
jsPlumbInstance.setContainer('#dropContainer');
jsPlumbInstance.bind('beforeDrop', beforeDrop);
jsPlumbInstance.bind('connection', onConnect);
jsPlumbInstance.bind('connectionDetached', onDetach);
jsPlumbInstance.bind('connectionMoved', onConnectionMoved);
jsPlumbInstance.bind('connectionAborted', onConnectionAborted);
function connectEndpoints(connection) {
return jsPlumbInstance.connect(connection, {
allowLoopback: false,
newConnection: true,
anchor: 'Continuous',
deleteEndpointsOnDetach: true
});
}
function addEndpoint(elem){
jsPlumbInstance.addEndpoint(elem.id, endpointOptions);
}
function generatePosition(){
var dc = $('#dropContainer');
var width = dc.css("width").replace('px', '');
var height = dc.css("height").replace('px', '');
return {
x: randInt(0, width),
y: randInt(0, height)
};
}
function addElementToBoard(element, random) {
var pos;
if(random)
pos = generatePosition();
else
pos = {
x: element.x,
y: element.y
};
var newe = $(renderBoardElement(element))
.css('top', pos.y + 'px')
.css('left', pos.x + 'px')[0];
$('#dropContainer').append(newe);
for(var i = 0; i < element.free_port_num; ++i){
addEndpoint(newe);
}
jsPlumbInstance.draggable(newe.id, {containment: true});
jsPlumbInstance.repaint(newe.id);
$(newe).bind('contextmenu', removeElement);
jsPlumbInstance.repaintEverything();
}
function selectElementFromList(ev) {
var elem = $(ev.target);
var obj = convertElement(elem);
elem.detach();
addElementToBoard(obj, true);
addNode(obj);
}
function addElementToList(elem) {
$('#dragContainer').append(renderListElement(elem));
$('#' + elem.id).click(selectElementFromList);
}
function removeElement(ev) {
var elem = $(ev.target);
var obj = convertElement(elem);
jsPlumbInstance.removeAllEndpoints(elem.attr('id'));
elem.detach();
addElementToList(obj);
removeNode(obj);
}
function clearWorkspace() {
jsPlumbInstance.deleteEveryConnection();
jsPlumbInstance.deleteEveryEndpoint();
$('.element').detach();
$('.unused-element').detach();
}
function initialize(result){
clearWorkspace();
old_nodes = new Set();
add_nodes = new Set();
remove_nodes = new Set();
$.each(result.elements, (i, element) => {
addElementToBoard(element);
old_nodes.add(element.id);
add_nodes.add(element.id);
});
$.each(result.nongraph_elements, (i, element) => {
addElementToBoard(element, true);
add_nodes.add(element.id);
});
$.each(result.unused_elements, (i, element) => {
addElementToList(element);
});
old_connections = [];
$.each(result.connections, (i, connection) => {
var con = connectEndpoints(connection);
old_connections.push(convertConnection(con));
});
add_interfaces = []; // Because of a 'connect' event,
// connections are added to this list,
// but this is not necessary.
remove_interfaces = [];
}
function save() {
var data = {
add_interfaces: cleanListElements(add_interfaces),
remove_interfaces: cleanListElements(remove_interfaces),
add_nodes: convertListForSaving(add_nodes),
remove_nodes: convertListForSaving(remove_nodes)
};
$.post('', JSON.stringify(data), initialize, 'json');
}
$.get('', initialize);
$("#saveButton").click(save);
$('#searchField').on('keyup', filter);
var vmFilter = new SwitchButton('vm-filter', true);
vmFilter.afterChanged(filter);
var netFilter = new SwitchButton('net-filter', true);
netFilter.afterChanged(filter);
function filter() {
$(".unused-element").each((i, elem) => {
elem = $(elem);
elem.hide();
var key = $("#searchField").val().toLowerCase();
var type = elem.attr('type');
var network_on = netFilter.isChecked();
var vm_on = vmFilter.isChecked();
if(elem.attr("name").toLowerCase().indexOf(key) >= 0 &&
((type === "network" && network_on) || (type === "vm" && vm_on)))
elem.show();
});
}
});
{% extends "dashboard/base.html" %}
{% load staticfiles %}
{% load i18n %}
{% load pipeline %}
{% block title-page %}{% trans 'Network Editor' %}{% endblock %}
{% block extra_css %}
{% stylesheet "network-editor" %}
{% endblock %}
{% block content %}
<div class="flex-container" id="workspace">
<div class="panel panel-default text-center" id="dragPanel">
<div class="panel-heading">
<div class="row">
<div class="col-md-9 text-left">
<h3 class="no-margin"><i class="fa fa-sitemap"></i> {% trans 'Editor' %}</h3>
</div>
<div class="col-md-3 text-left">
<button class="btn btn-success btn-xs" id="saveButton"><i class="fa fa-floppy-o"></i></button>
</div>
</div>
</div>
<div class="panel-heading text-center">
<div id="filterConatiner">
<div class="row">
<input type="text" class="form-control" id="searchField" placeholder="{% trans 'Search' %}"/><br />
</div>
<div class="row">
<div class="col-md-6">
<i id="vm-filter"></i> <i class="fa fa-desktop"></i> vm
</div>
<div class="col-md-6">
<i id="net-filter"></i> <i class="fa fa-sitemap"></i> net
</div>
</div>
</div>
</div>
<div class="panel-body" id="dragContainer">
</div>
</div>
<div class="" id="dropContainer" oncontextmenu="return false;"></div>
</div>
{% endblock %}
{% block extra_js %}
{% javascript "network-editor" %}
{% endblock %}
...@@ -31,7 +31,7 @@ from .views import ( ...@@ -31,7 +31,7 @@ from .views import (
FirewallList, FirewallDetail, FirewallCreate, FirewallDelete, FirewallList, FirewallDetail, FirewallCreate, FirewallDelete,
remove_host_group, add_host_group, remove_host_group, add_host_group,
remove_switch_port_device, add_switch_port_device, remove_switch_port_device, add_switch_port_device,
VlanAclUpdateView VlanAclUpdateView, NetworkEditorView
) )
urlpatterns = [ urlpatterns = [
...@@ -135,6 +135,10 @@ urlpatterns = [ ...@@ -135,6 +135,10 @@ urlpatterns = [
url('^vxlans/delete/(?P<vni>\d+)/$', VxlanDelete.as_view(), url('^vxlans/delete/(?P<vni>\d+)/$', VxlanDelete.as_view(),
name="network.vxlan-delete"), name="network.vxlan-delete"),
# editor
url('^editor/$', NetworkEditorView.as_view(),
name="network.editor"),
# non class based views # non class based views
url('^hosts/(?P<pk>\d+)/remove/(?P<group_pk>\d+)/$', remove_host_group, url('^hosts/(?P<pk>\d+)/remove/(?P<group_pk>\d+)/$', remove_host_group,
name='network.remove_host_group'), name='network.remove_host_group'),
......
...@@ -17,11 +17,12 @@ ...@@ -17,11 +17,12 @@
import logging import logging
import random import random
import json
from collections import OrderedDict from collections import OrderedDict
from netaddr import IPNetwork from netaddr import IPNetwork
from django.views.generic import ( from django.views.generic import (
TemplateView, UpdateView, DeleteView, CreateView TemplateView, UpdateView, DeleteView, CreateView,
) )
from django.core.exceptions import ( from django.core.exceptions import (
ValidationError, PermissionDenied, ImproperlyConfigured ValidationError, PermissionDenied, ImproperlyConfigured
...@@ -38,8 +39,8 @@ from firewall.models import ( ...@@ -38,8 +39,8 @@ from firewall.models import (
Host, Vlan, Domain, Group, Record, BlacklistItem, Rule, VlanGroup, Host, Vlan, Domain, Group, Record, BlacklistItem, Rule, VlanGroup,
SwitchPort, EthernetDevice, Firewall SwitchPort, EthernetDevice, Firewall
) )
from network.models import Vxlan from network.models import Vxlan, EditorElement
from vm.models import Interface from vm.models import Interface, Instance
from common.views import CreateLimitedResourceMixin from common.views import CreateLimitedResourceMixin
from acl.views import CheckedObjectMixin from acl.views import CheckedObjectMixin
from .tables import ( from .tables import (
...@@ -1107,6 +1108,192 @@ class VxlanDelete(LoginRequiredMixin, CheckedObjectMixin, DeleteView): ...@@ -1107,6 +1108,192 @@ class VxlanDelete(LoginRequiredMixin, CheckedObjectMixin, DeleteView):
return context return context
class NetworkEditorView(LoginRequiredMixin, TemplateView):
template_name = 'network/editor.html'
def get(self, *args, **kwargs):
if self.request.is_ajax():
connections = self._get_connections()
ngelements = self._get_nongraph_elements(connections)
ngelements = self._serialize_elements(ngelements)
connections = map(lambda con: {
'source': 'vm-%s' % con['source'].pk,
'target': 'net-%s' % con['target'].vni,
}, connections['connections'])
unused_elements = self._get_unused_elements()
unused_elements = self._serialize_elements(unused_elements)
return JsonResponse({
'elements': map(lambda e: e.as_data(),
EditorElement.objects.filter(
owner=self.request.user)),
'nongraph_elements': ngelements,
'unused_elements': unused_elements,
'connections': connections,
})
return super(NetworkEditorView, self).get(*args, **kwargs)
def post(self, *args, **kwargs):
data = json.loads(self.request.body)
add_ifs = data.get('add_interfaces', [])
remove_ifs = data.get('remove_interfaces', [])
add_nodes = data.get('add_nodes', [])
remove_nodes = data.get('remove_nodes', [])
# Add editor element
self._element_list_operation(add_nodes, self._update_element)
# Remove editor element
self._element_list_operation(remove_nodes, self._remove_element)
# Add interface
self._interface_list_operation(add_ifs, self._add_interface)
# Remove interface
self._interface_list_operation(remove_ifs, self._remove_interface)
return self.get(*args, **kwargs)
def _max_port_num_helper(self, model, attr_name):
if not hasattr(self, attr_name):
value = model.get_objects_with_level(
'user', self.request.user).count()
setattr(self, attr_name, value)
return getattr(self, attr_name)
@property
def vm_max_port_num(self):
return self._max_port_num_helper(Vxlan, '_vm_max_port_num')
@property
def vxlan_max_port_num(self):
return self._max_port_num_helper(Instance, '_vxlan_max_port_num')
def _vm_serializer(self, vm):
max_port_num = self.vm_max_port_num
vxlans = Vxlan.get_objects_with_level(
'user', self.request.user).values_list('pk', flat=True)
free_port_num = max_port_num - vm.interface_set.filter(
vxlan__pk__in=vxlans).count()
return {
'name': unicode(vm),
'id': 'vm-%s' % vm.pk,
'description': vm.description,
'type': 'vm',
'icon': 'fa-desktop',
'free_port_num': free_port_num,
}
def _vxlan_serializer(self, vxlan):
max_port_num = self.vxlan_max_port_num
vms = Instance.get_objects_with_level(
'user', self.request.user).values_list('pk', flat=True)
free_port_num = max_port_num - Interface.objects.filter(
vxlan=vxlan, instance__pk__in=vms).count()
return {
'name': vxlan.name,
'id': 'net-%s' % vxlan.vni,
'description': vxlan.description,
'type': 'network',
'icon': 'fa-sitemap',
'free_port_num': free_port_num,
}
def _get_unused_elements(self):
connections = self._get_connections()
vms = map(lambda vm: vm.id, connections['vms'])
vxlans = map(lambda vxlan: vxlan.vni, connections['vxlans'])
eelems = EditorElement.objects.filter(owner=self.request.user)
vm_query = Q(pk__in=vms) | Q(editor_elements__in=eelems)
vms = Instance.get_objects_with_level(
'user', self.request.user).exclude(vm_query)
vxlan_query = Q(vni__in=vms) | Q(editor_elements__in=eelems)
vxlans = Vxlan.get_objects_with_level(
'user', self.request.user).exclude(vxlan_query)
return {
'vms': vms,
'vxlans': vxlans,
}
def _get_nongraph_elements(self, connections):
return {
'vms': filter(lambda v: not v.editor_elements.exists(),
connections['vms']),
'vxlans': filter(lambda v: not v.editor_elements.exists(),
connections['vxlans']),
}
def _get_connections(self):
""" Returns connections and theirs participants. """
vms = Instance.get_objects_with_level('user', self.request.user)
connections = []
vm_set = set()
vxlan_set = set()
for vm in vms:
for intf in vm.interface_set.filter(vxlan__isnull=False):
vm_set.add(vm)
vxlan_set.add(intf.vxlan)
connections.append({
'source': vm,
'target': intf.vxlan,
})
return {
'connections': connections,
'vms': vm_set,
'vxlans': vxlan_set,
}
def _serialize_elements(self, elements):
return (map(self._vm_serializer, elements['vms']) +
map(self._vxlan_serializer, elements['vxlans']))
def _get_modifiable_object(self, model, connection,
attr_name, filter_attr):
value = connection.get(attr_name)
if value is not None:
value = model.get_objects_with_level(
'user', self.request.user).filter(
**{filter_attr: value}).first()
return value
def _element_list_operation(self, node_list, operation):
for e in node_list:
elem = dict(e)
type = elem.pop('type')
id = elem.pop('id')
model = Instance if type == 'vm' else Vxlan
filter = {'pk': id} if type == 'vm' else {'vni': id}
object = model.get_objects_with_level(
'user', self.request.user).get(**filter)
operation(object.editor_elements, elem)
def _update_element(self, elements, elem):
elements.update_or_create(owner=self.request.user,
defaults=elem)
def _remove_element(self, elements, elem):
elements.filter(owner=self.request.user).delete()
def _interface_list_operation(self, if_list, operation):
for con in if_list:
vm = self._get_modifiable_object(Instance, con, 'source', 'pk')
vxlan = self._get_modifiable_object(Vxlan, con, 'target', 'vni')
if vm and vxlan:
operation(vm, vxlan)
def _add_interface(self, vm, vxlan):
vm.add_user_interface(
user=self.request.user, vxlan=vxlan, system=vm.system)
def _remove_interface(self, vm, vxlan):
intf = vm.interface_set.filter(vxlan=vxlan).first()
if intf:
vm.remove_user_interface(
interface=intf, user=self.request.user, system=vm.system)
def remove_host_group(request, **kwargs): def remove_host_group(request, **kwargs):
host = Host.objects.get(pk=kwargs['pk']) host = Host.objects.get(pk=kwargs['pk'])
group = Group.objects.get(pk=kwargs['group_pk']) group = Group.objects.get(pk=kwargs['group_pk'])
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment