Better QGIS forms, part three🔗

Posted by Médéric Ribreux 🗓 In blog/ Qgis/

Introduction

This article is the last of my series of articles about QGIS forms. You can read the previous one here.

Today, we will focus on n,n relations. n,n is a database syntax to tell that one object of a table can have multiples values linked to another reference table. On a database schema, it looks like the following:

Image of n,n table schema
Image of n,n table schema

You can see that n,n relations are materialized by two 1,n relations, involving three tables:

With such a mechanism, we are able to link multiple pesticides to multiple analysis without having to store a list into the ANALYSIS table. Instead, the central table (ANALYSIS_PESTICIDE) is used to make a link. Everytime you would like to implement n,n relations, just think about this intermediate table.

For the moment, QGIS doesn't support n,n tables on forms (but it supports 1,n with sub-forms). There is no control dedicated to that. But we can code it !

User Interface

What to do ?

Before diving into code, we need to solve the UI problem. What control are we going to use ? As we are not constrained by QGIS existing controls, we can imagine the following:

Main form: analysis form

Here is a mockup of the analysis form:

Image of analysis form mockup
Image of analysis form mockup

You can see that the last form control is a bit special: it is our QListWidget which lists all the pesticides that have been found in the displayed analysis. The list only displays the relevant information and when the control is not large enough, you can use a vertical scroll bar. Elements of the list can be selected (multi or mono depending on QListWidget attributes) and you can copy/paste them in the clipboard. Whenever you need to have some information about the analysis, everything is displayed in only one form.

This form can't be auto-generated by QGIS because the QListWidget is mandatory and there is no field to hold pesticides values in ANALYSIS table. For n,n relations you have to use a custom ui form with qt4-designer like the following:

Image of Qtdesigner of the custom form for displaying pesticides results
Image of Qtdesigner of the custom form for displaying pesticides results

Furthermore, printing information in the QListWidget of this form needs some code for a dedicated function in Python to retrieve results from the ANALYSIS_PESTICIDE table. We will study this in the code part below.

What about editing pesticides into this analysis?

Pesticide form

When you click on the "Modify" button, the following dialog will be displayed:

Image pesticide dialog box
Image pesticide dialog box

The dialog box is very simple: on the top, you have a QLineEdit which will be used to type the name of the pesticide you want. The main control of the dialog is a QListWidget with the name of all the pesticides. There is a checkbox to add or remove pesticides to the analysis. Checking a box will add the pesticide into the ANALYSIS_PESTICIDE table, unchecking will delete it from the table. With this dialog, you can add or delete as many pesticides you want for one analysis without bothering with other controls.

Whenever your modifications are made, results will be committed into ANALYSIS_PESTICIDE table and this needs dedicated code.

Using QGIS relations and value relations

Now that we have studied the concepts of the UI part, we are ready to go further. Our approach seems to be good with one n,n relation. But imagine that you want to build a true complex GIS application that involves about 50 n,n relations. You can't put everything into code, it will take too much time to develop and to maintain. You will surely make a lot of mistakes in the names of all the controls into Python code. So we need to be a little bit more generic.

QGIS has already a mechanism to handle 1,n relations: it is called "Relations". Relations are a way for QGIS to know that a table is linked to another. It is used to show sub-forms inside a parent form. So, could we try to use two 1,n relations and deal with it into code ? I have tried this but there is something more efficient. Creating two relations (first from ANALYSIS to ANALYSIS_PESTICIDE and second from PESTICIDE TO ANALYSIS_PESTICIDE) seems to be the good way but you have to remember what we want. We would like to display a list of pesticides names in the control and store the ID_PESTICIDE into ANALYSIS_PESTICIDE and there is nothing in a QGIS relation to tell that you want to display a field.

But there is something inside QGIS to deal with and it's called "Value Relation". When you define a Value Relation for a field, you are linking values from another layer and you can choose what field to show and what field is the ID. So instead of creating two relations, we could do the following:

Here is the definition of the relation:

Image pesticide dialog box
Image pesticide dialog box

The name/id of the relation should be the same than the QListWidget of the custom .ui file.

Here is the definition of the Value Relation:

Image pesticide dialog box
Image pesticide dialog box

This is a classic Value Relation configuration. It is made in the ANALYSIS_PESTICIDE table on the ID_PESTICIDE attribute.

The relation is used as the following:

The Value Relation is used as the following:

The relation will be used inside ANALYSIS form to make the link between ANALYSIS and ANALYSIS_PESTICIDE. The QListWidget needs to have the name of the relation for the code to have a way to find which tables are involved. Value Relation is for the other part of the n,n relation: ANALYSIS_PESTICIDE to PESTICIDE.

We are done with the concepts !

Show me the code !

n,n dedicated dialog

Time to dive into Python…

First thing to do: the pesticide UI ! With PyQt you can create a .ui file and build it with qt4-designer. But loading a .ui file from Python can be unsafe: you have to deal with the file location. As the pesticide UI is very trivial, I prefer to build it with code. So, here is the Python code of the dialog:

def setupUi(self):
    """
    Builds the QDialog
    """
    # Form building
    self.setObjectName(u"nnDialog")
    self.resize(550, 535)
    self.setMinimumSize(QtCore.QSize(0, 0))
    self.buttonBox = QtGui.QDialogButtonBox(self)
    self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
    self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
    self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
    self.buttonBox.setObjectName(u"buttonBox")
    self.verticalLayoutWidget = QtGui.QWidget(self)
    self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
    self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
    self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
    self.verticalLayout.setMargin(0)
    self.verticalLayout.setObjectName(u"verticalLayout")
    self.horizontalLayout = QtGui.QHBoxLayout()
    self.horizontalLayout.setObjectName(u"horizontalLayout")
    self.label = QtGui.QLabel(self.verticalLayoutWidget)
    self.label.setObjectName(u"label")
    self.horizontalLayout.addWidget(self.label)
    self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
    self.SEARCH.setObjectName(u"SEARCH")
    self.horizontalLayout.addWidget(self.SEARCH)
    self.verticalLayout.addLayout(self.horizontalLayout)
    self.horizontalLayout_2 = QtGui.QHBoxLayout()
    self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
    self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
    self.LIST.setObjectName(u"LIST")
    self.horizontalLayout_2.addWidget(self.LIST)
    self.verticalLayout.addLayout(self.horizontalLayout_2)

    self.buttonBox.accepted.connect(self.accept)
    self.buttonBox.rejected.connect(self.reject)
    QtCore.QMetaObject.connectSlotsByName(self)

Well, this is what you can have from pyuic4 from a qt4-designer .ui file. But this time you don't need the file anymore.

n,n list behaviour functions

Next thing to do is to populate the n,n dialog. We also need to add the "search engine" functions and a way to pre-check values that are in the ANALYSIS_PESTICIDE table for the current analysis. And at the end, we need to send the checked values to the main form (ANALYSIS one) in order to make the database update and to update the form control.

I've created a class for this:

class nnDialog(QtGui.QDialog):
    """
    Dedicated n,n relations Form Class
    """

    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
    	"""Constructor"""
    	QtGui.QDialog.__init__(self,parent)

    	self.initValues = initValues
    	self.shownField = shownField
    	self.layer =  layer
    	self.IdField = IdField
    	self.search = search
    	if self.layer is None and DEBUGMODE:
    		QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"nnForms", QgsMessageLog.INFO)

    	# Build the GUI and populate the list with the good values
    	self.setupUi()
    	self.populateList()

    	# Add dynamic control when list is changing
    	self.SEARCH.textChanged.connect(self.populateList)
    	self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
    	"""Builds the QDialog"""
    	# Form building
    	self.setObjectName(u"nnDialog")
    	self.resize(550, 535)
    	self.setMinimumSize(QtCore.QSize(0, 0))
    	self.buttonBox = QtGui.QDialogButtonBox(self)
    	self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
    	self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
    	self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
    	self.buttonBox.setObjectName(u"buttonBox")
    	self.verticalLayoutWidget = QtGui.QWidget(self)
    	self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
    	self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
    	self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
    	self.verticalLayout.setMargin(0)
    	self.verticalLayout.setObjectName(u"verticalLayout")
    	self.horizontalLayout = QtGui.QHBoxLayout()
    	self.horizontalLayout.setObjectName(u"horizontalLayout")
    	self.label = QtGui.QLabel(self.verticalLayoutWidget)
    	self.label.setObjectName(u"label")
    	self.horizontalLayout.addWidget(self.label)
    	self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
    	self.SEARCH.setObjectName(u"SEARCH")
    	self.horizontalLayout.addWidget(self.SEARCH)
    	self.verticalLayout.addLayout(self.horizontalLayout)
    	self.horizontalLayout_2 = QtGui.QHBoxLayout()
    	self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
    	self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
    	self.LIST.setObjectName(u"LIST")
    	self.horizontalLayout_2.addWidget(self.LIST)
    	self.verticalLayout.addLayout(self.horizontalLayout_2)

    	self.buttonBox.accepted.connect(self.accept)
    	self.buttonBox.rejected.connect(self.reject)
    	QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
    	"""Whenever a checkbox is checked, modify the values"""
    	# Check if we check or uncheck the value:
    	if element.checkState() == Qt.Checked:
    		self.initValues.append(element.data(Qt.UserRole))
    	else:
    		self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
    	"""Fill the QListWidget with values"""
    	# Delete everything
    	self.LIST.clear()

    	# We need a request
    	request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
    	if txtFilter is not None:
    		fields = self.layer.dataProvider().fields()
    		fieldname = fields[self.shownField].name()
    		request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

    	# Grab the results from the layer
    	features = self.layer.getFeatures(request)

    	for feature in sorted(features, key = lambda f: f[0]):
    		attr = feature.attributes()
    		value = attr[self.shownField]
    		element = QListWidgetItem(value)
    		element.setData(Qt.UserRole, attr[self.IdField])

    		# initValues will be checked
    		if attr[self.IdField] in self.initValues:
    			element.setCheckState(Qt.Checked)
    		else:
    			element.setCheckState(Qt.Unchecked)
    		self.LIST.addItem(element)

def getValues(self):
    """
    Return the selected values of the QListWidget
    """
    return self.initValues

The class is named nnDialog and it deals with the n,n dialog used to add/remove pesticides of the current analysis. The constructor is very simple:

The changeValues method is called when you check a checkBox in the list. Whenever there is action, the ID_PESTICIDE value is added/removed from initValues.

The populateList method is used when the n,n dialog is opened (called by the constructor) and whenever there is some changes in the search text bar. This method is used to populate the list:

nnDialog class implements all the logic of the n,n Dialog and it's code is quite generic: every parameters are transmitted by the constructor. This class is used when you click on the "Modify" button at the right of the list of pesticides in the ANALYSIS form.

But to stay generic we have also to be generic with the code which triggers nnDialog…

Main form code

Last thing to do: add logic to the ANALYSIS form. Here is the code:

class nnForm:
    """Class to handle forms to type data"""
    def __init__(self, dialog, layerid, featureid):    
    	self.dialog = dialog
    	self.layerid = layerid
    	self.featureid = featureid
    	self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
    	self.search = False

    def id2listWidget(self, table, values, listWidget):
    	'''Show all the selected values of a link table on a QListWidget'''
    	# Find the Widget
    	if listWidget is None or table is None:
    		QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "DBPAT", QgsMessageLog.INFO)
    		return False

    	# Empty the list
    	listWidget.clear()

    	# Get the params (for the first child table)
    	if self.valueRelationParams(table):
    		params = self.valueRelationParams(table)[0]
    	if params is None or not params:
    		QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
    		return False

    	# Get target layer:
    	tgtLayer = params['tgtLayer']

    	# Handle values: need to escape \' characters
    	values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

    	## Then, get the real values from other-side table
    	if values:
    		request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
    		if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
    			query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
    		else:
    			query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
    		request.setFilterExpression(query)

    		# and display them in the QListWidget
    		for feature in tgtLayer.getFeatures(request):
    			value = feature.attributes()[params[u'tgtValueIdx']]
    			if value != u"NULL":
    				element = QListWidgetItem(value)
    				element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
    				listWidget.addItem(element)

    	return True

    def valueRelationParams(self,layer):
    	'''Function that returns the configuration parameters of a valueRelation as a list of dict'''
    	params = []
    	if layer is not None:
    		for idx, field in enumerate(layer.dataProvider().fields()):
    			if layer.editorWidgetV2(idx) == u"ValueRelation":
    				param = {}
    				param[u'srcId'] = field.name()
    				param[u'srcIdIdx'] = idx
    				if u"Layer" in layer.editorWidgetV2Config(idx):
    					tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
    					tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
    					if tgtLayer is None:
    						QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
    						return False

    					param[u'tgtLayer'] = tgtLayer
    					param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
    					param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

    					# Find index of all fields:
    					for indx, f in enumerate(tgtLayer.dataProvider().fields()):
    						if f.name() == param[u'tgtId']:
    							param[u'tgtIdIdx'] = indx
    							param[u'tgtIdType'] = f.type()
    						if f.name() == param[u'tgtValue']:
    							param[u'tgtValueIdx'] = indx
    					params.append(param)

    	# notification
    	if not params:
    		QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

    	return params

    def manageMultiple(self):
    	'''Handle specifics thesaurus form'''
    	# Scan all of the QgsRelations of the project
    	relations = QgsProject.instance().relationManager().relations()

    	for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
    		listName = listWidget.objectName()
    		if listName not in relations.keys():
    			QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
    			continue

    		# Find what is the table to show
    		relation = relations[listName]
    		shownLayer = relation.referencingLayer()

    		# Find other side of n,n relation
    		if self.valueRelationParams(shownLayer):
    			params = self.valueRelationParams(shownLayer)[0]
    		if params is None:
    			continue

    		# When found, we are ready to populate the QListWidget with the good values
    		values = []
    		if self.featureid:
    			# Get the features to display
    			request = relation.getRelatedFeaturesRequest(self.featureid)
    			request.setFlags(QgsFeatureRequest.NoGeometry)
    			for feature in shownLayer.getFeatures(request):
    				values.append(feature.attributes()[params[u'srcIdIdx']])
    			self.id2listWidget(shownLayer, values, listWidget)

    		buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
    		if buttonWidget:
    			if self.search or self.layerid.isEditable():
    				buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
    				buttonWidget.setEnabled(True)
    			else:
    				buttonWidget.setEnabled(False)
    		elif DEBUGMODE:
    			QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
    	'''Open a dedicated dialog form with values taken from a child table.'''
    	table = relation.referencingLayer()
    	if self.valueRelationParams(table):
    		params = self.valueRelationParams(table)[0]

    	if params is None or not params:
    		QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
    		return False

    	if widget is None:
    		QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

    	# Open the form with the good values
    	dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

    	# handle results
    	if dialog.exec_():
    		# Get the results:
    		thevalues = dialog.getValues()

    		# Modify target table if we have a featureid
    		if self.featureid:
    			table.startEditing()
    			caps = table.dataProvider().capabilities()
    			## Delete all the previous values
    			if caps & QgsVectorDataProvider.DeleteFeatures:
    				request = relation.getRelatedFeaturesRequest(self.featureid)
    				request.setFlags(QgsFeatureRequest.NoGeometry)
    				fids = [f.id() for f in table.getFeatures(request)]
    				table.dataProvider().deleteFeatures(fids)

    			## Add the new values
    			if caps & QgsVectorDataProvider.AddFeatures:
    				for value in thevalues:
    					feat = QgsFeature()
    					feat.setAttributes([None, self.featureid.attributes()[0], value])
    					table.dataProvider().addFeatures([feat])
    			## Commit changes
    			table.commitChanges()

    		# refresh listWidget aspect
    		self.id2listWidget(table, thevalues, widget)

The nnForm class will manage the form of ANALYSIS (or every form that has the same class Python function).

The manageMultiple method, will "scan" the layer form to find all QListWidgets with the same name than a relation. For each of those QListWidgets, we try to find what is the intermediate table (ANALYSIS_PESTICIDE) and what is the last table (from Value Relation). Then the QListWidget is populated with the values from ANALYSIS_PESTICIDE (and by retreiving the pesticides names). At last, the QPushButton that is named like the relation (+_B) is connected to a method which will open a nnDialog (see previous chapter).

OpenSubForm method is used to create the nnDialog (from the same named class), to give it the already checked values and to grab the result once the nnDialog dialog is closed. Most of the code of this method is for updating values with quite a brutal approach: we erase every data stored into ANALYSIS_PESTICIDE that have the same ID_ANALYSIS value than the current analysis ! Then, we re-add everything… But it seems to be faster than filtering the already checked values ! At last, th QListWidget involved is refreshed.

id2listWidget is the method used to populate and refresh a QListWidget with relations on the form. Everything is first cleared. A request to the last table is done (PESTICIDE) to grab the field that msut be shown (NAME). The values (IDs) are requested before and put into the constructor of this method.

valueRelationParams is used to find what are: the target layer, the shown field, the identifying field of a value relation control configuration of a table. It is used in manageMultiple and id2listWidget methods to find what to display.

Putting everything into one file

# -*- coding: utf-8 -*-

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from qgis.core import QgsMapLayerRegistry, QgsMessageLog, QgsFeatureRequest, QgsFeature
from qgis.core import QgsRelationManager, QgsRelation, QgsProject, QgsVectorDataProvider
from qgis.utils import iface
from functools import partial
from PyQt4 import QtCore, QtGui

# Global variables
DEBUGMODE = True

class nnDialog(QtGui.QDialog):
'''Dedicated n,n relations Form Class'''
    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
    	'''Constructor'''
    	QtGui.QDialog.__init__(self,parent)

    	self.initValues = initValues
    	self.shownField = shownField
    	self.layer =  layer
    	self.IdField = IdField
    	self.search = search
    	if self.layer is None and DEBUGMODE:
    		QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"Your App", QgsMessageLog.INFO)

    	# Build the GUI and populate the list with the good values
    	self.setupUi()
    	self.populateList()

    	# Add dynamic control when list is changing
    	self.SEARCH.textChanged.connect(self.populateList)
    	self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
    	'''Builds the QDialog'''
    	# Form building
    	self.setObjectName(u"nnDialog")
    	self.resize(550, 535)
    	self.setMinimumSize(QtCore.QSize(0, 0))
    	self.buttonBox = QtGui.QDialogButtonBox(self)
    	self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
    	self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
    	self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
    	self.buttonBox.setObjectName(u"buttonBox")
    	self.verticalLayoutWidget = QtGui.QWidget(self)
    	self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
    	self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
    	self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
    	self.verticalLayout.setMargin(0)
    	self.verticalLayout.setObjectName(u"verticalLayout")
    	self.horizontalLayout = QtGui.QHBoxLayout()
    	self.horizontalLayout.setObjectName(u"horizontalLayout")
    	self.label = QtGui.QLabel(self.verticalLayoutWidget)
    	self.label.setObjectName(u"label")
    	self.horizontalLayout.addWidget(self.label)
    	self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
    	self.SEARCH.setObjectName(u"SEARCH")
    	self.horizontalLayout.addWidget(self.SEARCH)
    	self.verticalLayout.addLayout(self.horizontalLayout)
    	self.horizontalLayout_2 = QtGui.QHBoxLayout()
    	self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
    	self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
    	self.LIST.setObjectName(u"LIST")
    	self.horizontalLayout_2.addWidget(self.LIST)
    	self.verticalLayout.addLayout(self.horizontalLayout_2)

    	self.buttonBox.accepted.connect(self.accept)
    	self.buttonBox.rejected.connect(self.reject)
    	QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
    	'''Whenever a checkbox is checked, modify the values'''
    	# Check if we check or uncheck the value:
    	if element.checkState() == Qt.Checked:
    		self.initValues.append(element.data(Qt.UserRole))
    	else:
    		self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
    	'''Fill the QListWidget with values'''
    	# Delete everything
    	self.LIST.clear()

    	# We need a request
    	request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
    	if txtFilter is not None:
    		fields = self.layer.dataProvider().fields()
    		fieldname = fields[self.shownField].name()
    		request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

    	# Grab the results from the layer
    	features = self.layer.getFeatures(request)

    	for feature in sorted(features, key = lambda f: f[0]):
    		attr = feature.attributes()
    		value = attr[self.shownField]
    		element = QListWidgetItem(value)
    		element.setData(Qt.UserRole, attr[self.IdField])

    		# initValues will be checked
    		if attr[self.IdField] in self.initValues:
    			element.setCheckState(Qt.Checked)
    		else:
    			element.setCheckState(Qt.Unchecked)
    		self.LIST.addItem(element)

    def getValues(self):
    	'''Return the selected values of the QListWidget'''
    	return self.initValues

class nnForm:
'''Class to handle forms to type data'''
    def __init__(self, dialog, layerid, featureid):    
    	self.dialog = dialog
    	self.layerid = layerid
    	self.featureid = featureid
    	self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
    	self.search = False

    def id2listWidget(self, table, values, listWidget):
    	'''Show all the selected values of a link table on a QListWidget'''
    	# Find the Widget
    	if listWidget is None or table is None:
    		QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "nnForms", QgsMessageLog.INFO)
    		return False

    	# Empty the list
    	listWidget.clear()

    	# Get the params (for the first child table)
    	if self.valueRelationParams(table):
    		params = self.valueRelationParams(table)[0]
    	if params is None or not params:
    		QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
    		return False

    	# Get target layer:
    	tgtLayer = params['tgtLayer']

    	# Handle values: need to escape \' characters
    	values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

    	## Then, get the real values from other-side table
    	if values:
    		request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
    		if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
    			query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
    		else:
    			query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
    		request.setFilterExpression(query)

    		# and display them in the QListWidget
    		for feature in tgtLayer.getFeatures(request):
    			value = feature.attributes()[params[u'tgtValueIdx']]
    			if value != u"NULL":
    				element = QListWidgetItem(value)
    				element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
    				listWidget.addItem(element)

    	return True

    def valueRelationParams(self,layer):
    	'''Function that returns the configuration parameters of a valueRelation as a list of dict'''
    	params = []
    	if layer is not None:
    		for idx, field in enumerate(layer.dataProvider().fields()):
    			if layer.editorWidgetV2(idx) == u"ValueRelation":
    				param = {}
    				param[u'srcId'] = field.name()
    				param[u'srcIdIdx'] = idx
    				if u"Layer" in layer.editorWidgetV2Config(idx):
    					tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
    					tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
    					if tgtLayer is None:
    						QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
    						return False

    					param[u'tgtLayer'] = tgtLayer
    					param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
    					param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

    					# Find index of all fields:
    					for indx, f in enumerate(tgtLayer.dataProvider().fields()):
    						if f.name() == param[u'tgtId']:
    							param[u'tgtIdIdx'] = indx
    							param[u'tgtIdType'] = f.type()
    						if f.name() == param[u'tgtValue']:
    							param[u'tgtValueIdx'] = indx
    					params.append(param)

    	# notification
    	if not params:
    		QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

    	return params

    def manageMultiple(self):
    	'''Handle specifics thesaurus form'''
    	# Scan all of the QgsRelations of the project
    	relations = QgsProject.instance().relationManager().relations()

    	for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
    		listName = listWidget.objectName()
    		if listName not in relations.keys():
    			QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
    			continue

    		# Find what is the table to show
    		relation = relations[listName]
    		shownLayer = relation.referencingLayer()

    		# Find other side of n,n relation
    		if self.valueRelationParams(shownLayer):
    			params = self.valueRelationParams(shownLayer)[0]
    		if params is None:
    			continue

    		# When found, we are ready to populate the QListWidget with the good values
    		values = []
    		if self.featureid:
    			# Get the features to display
    			request = relation.getRelatedFeaturesRequest(self.featureid)
    			request.setFlags(QgsFeatureRequest.NoGeometry)
    			for feature in shownLayer.getFeatures(request):
    				values.append(feature.attributes()[params[u'srcIdIdx']])
    			self.id2listWidget(shownLayer, values, listWidget)

    		buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
    		if buttonWidget:
    			if self.search or self.layerid.isEditable():
    				buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
    				buttonWidget.setEnabled(True)
    			else:
    				buttonWidget.setEnabled(False)
    		elif DEBUGMODE:
    			QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
    	'''Open a dedicated dialog form with values taken from a child table.'''
    	table = relation.referencingLayer()
    	if self.valueRelationParams(table):
    		params = self.valueRelationParams(table)[0]

    	if params is None or not params:
    		QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
    		return False

    	if widget is None:
    		QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

    	# Open the form with the good values
    	dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

    	# handle results
    	if dialog.exec_():
    		# Get the results:
    		thevalues = dialog.getValues()

    		# Modify target table if we have a featureid
    		if self.featureid:
    			table.startEditing()
    			caps = table.dataProvider().capabilities()
    			## Delete all the previous values
    			if caps & QgsVectorDataProvider.DeleteFeatures:
    				request = relation.getRelatedFeaturesRequest(self.featureid)
    				request.setFlags(QgsFeatureRequest.NoGeometry)
    				fids = [f.id() for f in table.getFeatures(request)]
    				table.dataProvider().deleteFeatures(fids)

    			## Add the new values
    			if caps & QgsVectorDataProvider.AddFeatures:
    				for value in thevalues:
    					feat = QgsFeature()
    					feat.setAttributes([None, self.featureid.attributes()[0], value])
    					table.dataProvider().addFeatures([feat])
    			## Commit changes
    			table.commitChanges()

    		# refresh listWidget aspect
    		self.id2listWidget(table, thevalues, widget)

def opennnForm(dialog, layerid, featureid):
    '''Generic function to open a nnForm'''
    form = nnForm(dialog, layerid, featureid)
    QgsMessageLog.logMessage(u"opennnForm !", "nnforms", QgsMessageLog.INFO)
    form.manageMultiple()

Conclusion

Okay, this one is quite complex! If you want to implement n,n forms, you have to code because QGIS is not able to handle them for the moment. For a true implementation into QGIS code, I would take a different path.

I can imagine to have a new relation type dedicated to n,n relations. In those relations, you would have to:

Once in the form, you would use a new form control to configure: