Better QGIS forms, part three🔗
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:
You can see that n,n relations are materialized by two 1,n relations, involving three tables:
- The first data table (ANALYSIS).
- The second data table (PESTICIDE).
- A relation table (ANALYSIS_PESTICIDE) that make the connection between the two tables mentionned above.
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:
- The main source of data is ANALYSIS: we want to add multiple pesticides from one analysis.
- We only need the relevant information: which pesticides have been measured during the analysis.
- If you have a catalog of ten thousand of pesticides, there is no need to show the whole list on the analysis form.
- So we need a dedicated dialog for selecting pesticides. In this sub-form, the list of all the pesticides will be presented to the user in order to make a choice.
- The pesticides table will store all informations about pesticides. We need to have a list of the pesticide's names and be able to choose multiple pesticides entries.
- The simplest way to achieve this is to use a QListWidget with a checkbox for each pesticide entry. If the checkbox is checked, this analysis has this pesticide.
- We need a "search engine" to quickly find pesticides from their names if the PESTICIDE table is huge.
- Once selected, we need to only show the selected pesticides into analysis form.
Main form: analysis form
Here is a mockup of the analysis form:
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:
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:
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:
- Create only one relation: from ANALYSIS to ANALYSIS_PESTICIDE.
- Create a Value Relation control for ID_PESTICIDE of ANALYSIS_PESTICIDE towards ID_PESTICIDE of table PESTICIDE and show PESTICIDE.NAME (this is the field we want to display in the n,n sub dialog).
Here is the definition of the relation:
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:
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 code launched when the ANALYSIS form is opened knows that the layer of the form is ANALYSIS.
- with this name, it is easy to search inside the project relations (there is an API for that) where ANALYSIS is the parent layer of another.
- code will take the name of the child layer (ANALYSIS_PESTICIDE) and extract the shared attributes (ID_ANALYSIS).
- Now, we know from what table we must read the results to update the form (this will be ANALYSIS_PESTICIDE) and we also know what is the attribute to filter (ID_ANALYSIS) to have the corresponding value of the analysis that is displayed in the form.
The Value Relation is used as the following:
- We already know that the intermediate table is ANALYSIS_PESTICIDE.
- We search for its Value Relation form controls.
- In its configuration, we find what is the last table (PESTICIDE), what field is displayed (NAME) and what field is used as ID.
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:
- We need to know which layer will be displayed,
- what is the name of the field that will be displayed in the list,
- what is the name of the field used as ID,
- what are the values already checked (stored into ANALYSIS_PESTICIDE)
- once everything is transmitted by arguments to the constructor, we have to create the UI (see above),
- populate the list
- and add dynamic controls for search QLineEdit and QListWidget.
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:
- List is first cleared.
- If there is some text in the search QLineEdit, we make a request on the displayed field (NAME is our case) of the current layer (PESTICIDE) to retrieve only the correct values.
- Otherwise, we grab all the values of the layer.
- We sort them alphabetically.
- And for each value, we create a QListWidgetItem (element of a list) with a checkBox.
- If the value is in the initValues, it is checked, otherwise, it is unchecked.
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:
- Declare the "parent" layer (ANALYSIS in our case).
- Declare the "intermediate" table (ANALYSIS_PESTICIDE) and the shared attributes.
- Declare the "displayed" layer (PESTICIDE) and the shared attributes (with the intermediate table).
Once in the form, you would use a new form control to configure:
- the displayed field(s) or expression(s).
- if you want a search bar or not.
- if you want to filter values of the "displayed" layer.