Skip to content

Commit be01b7e

Browse files
committedDec 8, 2015
[FEATURE] Add N:M relation editing possibilities
This adds the possibility to manage data on a normalized relational database in N:M relations. On the relation editor in a form, the tools to add, delete, link and unlink work (also) on the linking table if a relation is visualized as N:M relation. Configuration is done through the fields tab where on the relation a second relation can be chosen (if there is a suitable relation in terms of a second relation on the linking table). Limitations =========== QGIS is not a database management system. It is based on assumptions about the underlying database system. In particular it expects * A `ON DELETE CASCADE` or similar measure on the second relation * Does not take care of setting the primary key when adding features. Either users need to be instructed to set them manually or - if it's a database derived value - the layers need to be in transaction mode (currently only activatable through the API)
1 parent 4160097 commit be01b7e

19 files changed

+942
-88
lines changed
 

‎ci/travis/linux/before_script.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
printf "[qgis_test]\nhost=localhost\ndbname=qgis_test\nuser=postgres" > ~/.pg_service.conf
22
psql -c 'CREATE DATABASE qgis_test;' -U postgres
33
psql -f $TRAVIS_BUILD_DIR/tests/testdata/provider/testdata.sql -U postgres -d qgis_test
4+
psql -f $TRAVIS_BUILD_DIR/tests/testdata/provider/reltests.sql -U postgres -d qgis_test

‎python/gui/editorwidgets/core/qgswidgetwrapper.sip

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
***************************************************************************/
1515

1616

17+
// This is required for the ConvertToSubClassCode to work properly
18+
// so RTTI for casting is available in the whole module.
19+
%ModuleCode
20+
#include "qgsrelationwidgetwrapper.h"
21+
%End
22+
1723
/**
1824
* Manages an editor widget
1925
* Widget and wrapper share the same parent
@@ -32,9 +38,10 @@ class QgsWidgetWrapper : QObject
3238
%End
3339

3440
%ConvertToSubClassCode
35-
QgsEditorWidgetWrapper* eww = qobject_cast<QgsEditorWidgetWrapper*>( sipCpp );
36-
if ( eww )
41+
if ( qobject_cast<QgsEditorWidgetWrapper*>( sipCpp ) )
3742
sipType = sipType_QgsEditorWidgetWrapper;
43+
else if ( qobject_cast<QgsRelationWidgetWrapper*>( sipCpp ) )
44+
sipType = sipType_QgsRelationWidgetWrapper;
3845
else
3946
sipType = 0;
4047
%End
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/***************************************************************************
2+
qgsrelationwidgetwrapper.h
3+
--------------------------------------
4+
Date : 14.5.2014
5+
Copyright : (C) 2014 Matthias Kuhn
6+
Email : matthias at opengis dot ch
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
17+
class QgsRelationWidgetWrapper : QgsWidgetWrapper
18+
{
19+
%TypeHeaderCode
20+
#include <qgsrelationwidgetwrapper.h>
21+
%End
22+
23+
public:
24+
explicit QgsRelationWidgetWrapper( QgsVectorLayer* vl, const QgsRelation& relation, QWidget* editor = 0, QWidget* parent /TransferThis/ = 0 );
25+
26+
protected:
27+
QWidget* createWidget( QWidget* parent );
28+
void initWidget( QWidget* editor );
29+
bool valid() const;
30+
31+
public slots:
32+
void setFeature( const QgsFeature& feature );
33+
};

‎python/gui/gui.sip

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
%Include qgsfieldvalidator.sip
7272
%Include qgsfiledropedit.sip
7373
%Include qgsfilterlineedit.sip
74+
%Include qgsfeatureselectiondlg.sip
7475
%Include qgsformannotationitem.sip
7576
%Include qgsgenericprojectionselector.sip
7677
%Include qgsgeometryrubberband.sip
@@ -126,6 +127,7 @@
126127
%Include qgsrasterlayersaveasdialog.sip
127128
%Include qgsrasterpyramidsoptionswidget.sip
128129
%Include qgsrubberband.sip
130+
%Include qgsrelationeditorwidget.sip
129131
%Include qgsscalecombobox.sip
130132
%Include qgsscalerangewidget.sip
131133
%Include qgsscalevisibilitydialog.sip
@@ -231,6 +233,7 @@
231233
%Include editorwidgets/qgsdoublespinbox.sip
232234
%Include editorwidgets/qgsrelationreferencewidget.sip
233235
%Include editorwidgets/qgsrelationreferencewidgetwrapper.sip
236+
%Include editorwidgets/qgsrelationwidgetwrapper.sip
234237
%Include editorwidgets/qgsspinbox.sip
235238
%Include editorwidgets/qgsdatetimeedit.sip
236239

‎python/gui/qgsfeatureselectiondlg.sip

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/***************************************************************************
2+
qgsfeatureselectiondlg.sip
3+
--------------------------------------
4+
Date : 30.11.2015
5+
Copyright : (C) 2015 Matthias Kuhn
6+
Email : matthias at opengis dot ch
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
// This is required for the ConvertToSubClassCode to work properly
17+
// so RTTI for casting is available in the whole module.
18+
%ModuleCode
19+
#include "qgsfeatureselectiondlg.h"
20+
%End
21+
22+
class QgsFeatureSelectionDlg : QDialog
23+
{
24+
%TypeHeaderCode
25+
#include "qgsfeatureselectiondlg.h"
26+
%End
27+
28+
%ConvertToSubClassCode
29+
if ( qobject_cast<QgsFeatureSelectionDlg*>( sipCpp ) )
30+
sipType = sipType_QgsFeatureSelectionDlg;
31+
else
32+
sipType = 0;
33+
%End
34+
35+
public:
36+
explicit QgsFeatureSelectionDlg( QgsVectorLayer* vl, QgsAttributeEditorContext &context, QWidget *parent /TransferThis/ = 0 );
37+
38+
/**
39+
* Get the selected features
40+
*
41+
* @return The selected feature ids
42+
*/
43+
const QgsFeatureIds& selectedFeatures();
44+
45+
/**
46+
* Set the selected features
47+
* @param ids The feature ids to select
48+
*/
49+
void setSelectedFeatures( const QgsFeatureIds& ids );
50+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/***************************************************************************
2+
qgsrelationeditorwidget.sip
3+
--------------------------------------
4+
Date : 28.11.2015
5+
Copyright : (C) 2015 Matthias Kuhn
6+
Email : matthias at opengis dot ch
7+
***************************************************************************
8+
* *
9+
* This program is free software; you can redistribute it and/or modify *
10+
* it under the terms of the GNU General Public License as published by *
11+
* the Free Software Foundation; either version 2 of the License, or *
12+
* (at your option) any later version. *
13+
* *
14+
***************************************************************************/
15+
16+
%ModuleCode
17+
#include "qgsrelationeditorwidget.h"
18+
%End
19+
20+
class QgsRelationEditorWidget : QgsCollapsibleGroupBox
21+
{
22+
%TypeHeaderCode
23+
#include <qgsrelationeditorwidget.h>
24+
%End
25+
26+
27+
%ConvertToSubClassCode
28+
if ( qobject_cast<QgsRelationEditorWidget*>( sipCpp ) )
29+
sipType = sipType_QgsRelationEditorWidget;
30+
else
31+
sipType = 0;
32+
%End
33+
34+
public:
35+
/**
36+
* @param parent parent widget
37+
*/
38+
QgsRelationEditorWidget( QWidget* parent /TransferThis/= 0 );
39+
40+
//! Define the view mode for the dual view
41+
void setViewMode( QgsDualView::ViewMode mode );
42+
43+
//! Get the view mode for the dual view
44+
QgsDualView::ViewMode viewMode();
45+
46+
void setRelationFeature( const QgsRelation& relation, const QgsFeature& feature );
47+
48+
/**
49+
* Set the relation(s) for this widget
50+
* If only one relation is set, it will act as a simple 1:N relation widget
51+
* If both relations are set, it will act as an N:M relation widget
52+
* inserting and deleting entries on the intermediate table as required.
53+
*
54+
* @param relation Relation referencing the edited table
55+
* @param nmrelation Optional reference from the referencing table to a 3rd N:M table
56+
*/
57+
void setRelations( const QgsRelation& relation, const QgsRelation& nmrelation );
58+
59+
void setFeature( const QgsFeature& feature );
60+
61+
void setEditorContext( const QgsAttributeEditorContext& context );
62+
63+
/**
64+
* The feature selection manager is responsible for the selected features
65+
* which are currently being edited.
66+
*/
67+
QgsIFeatureSelectionManager* featureSelectionManager();
68+
};

‎src/core/qgseditformconfig.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,32 @@ void QgsEditFormConfig::readXml( const QDomNode& node )
195195
QgsAttributeEditorElement *attributeEditorWidget = attributeEditorElementFromDomElement( elem, this );
196196
addTab( attributeEditorWidget );
197197
}
198+
199+
200+
//// TODO: MAKE THIS MORE GENERIC, SO INDIVIDUALL WIDGETS CAN NOT ONLY SAVE STRINGS
201+
/// SEE QgsEditorWidgetFactory::writeConfig
202+
203+
QDomElement widgetsElem = node.namedItem( "widgets" ).toElement();
204+
205+
QDomNodeList widgetConfigsElems = widgetsElem.childNodes();
206+
207+
for ( int i = 0; i < widgetConfigsElems.size(); ++i )
208+
{
209+
QgsEditorWidgetConfig cfg;
210+
211+
QDomElement wdgElem = widgetConfigsElems.at( i ).toElement();
212+
213+
QDomElement cfgElem = wdgElem.namedItem( "config" ).toElement();
214+
215+
for ( int j = 0; j < cfgElem.attributes().size(); ++j )
216+
{
217+
QDomAttr attr = cfgElem.attributes().item( j ).toAttr();
218+
cfg[attr.name()] = attr.value();
219+
}
220+
221+
setWidgetConfig( wdgElem.attribute( "name" ), cfg );
222+
}
223+
//// END TODO
198224
}
199225

200226
void QgsEditFormConfig::writeXml( QDomNode& node ) const
@@ -262,6 +288,40 @@ void QgsEditFormConfig::writeXml( QDomNode& node ) const
262288

263289
node.appendChild( tabsElem );
264290
}
291+
292+
//// TODO: MAKE THIS MORE GENERIC, SO INDIVIDUALL WIDGETS CAN NOT ONLY SAVE STRINGS
293+
/// SEE QgsEditorWidgetFactory::writeConfig
294+
295+
QDomElement widgetsElem = doc.createElement( "widgets" );
296+
297+
QMap<QString, QgsEditorWidgetConfig >::ConstIterator configIt( mWidgetConfigs.constBegin() );
298+
299+
while ( configIt != mWidgetConfigs.constEnd() )
300+
{
301+
if ( mFields.indexFromName( configIt.key() ) == -1 )
302+
{
303+
QDomElement widgetElem = doc.createElement( "widget" );
304+
widgetElem.setAttribute( "name", configIt.key() );
305+
306+
QDomElement configElem = doc.createElement( "config" );
307+
widgetElem.appendChild( configElem );
308+
309+
QgsEditorWidgetConfig::ConstIterator cfgIt( configIt.value().constBegin() );
310+
311+
while ( cfgIt != configIt.value().constEnd() )
312+
{
313+
configElem.setAttribute( cfgIt.key(), cfgIt.value().toString() );
314+
++cfgIt;
315+
}
316+
317+
widgetsElem.appendChild( widgetElem );
318+
}
319+
++configIt;
320+
}
321+
322+
node.appendChild( widgetsElem );
323+
324+
//// END TODO
265325
}
266326

267327
QgsAttributeEditorElement* QgsEditFormConfig::attributeEditorElementFromDomElement( QDomElement &elem, QObject* parent )

‎src/gui/attributetable/qgsattributetablemodel.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ void QgsAttributeTableModel::loadLayer()
370370
{
371371
++i;
372372

373+
QgsDebugMsg( QString( "Next feature %1" ).arg( i ) );
374+
373375
if ( t.elapsed() > 1000 )
374376
{
375377
bool cancel = false;
@@ -486,6 +488,7 @@ int QgsAttributeTableModel::fieldCol( int idx ) const
486488

487489
int QgsAttributeTableModel::rowCount( const QModelIndex &parent ) const
488490
{
491+
QgsDebugMsg( QString( "Row Count %1" ).arg( mRowIdMap.size() ) );
489492
Q_UNUSED( parent );
490493
return mRowIdMap.size();
491494
}

‎src/gui/editorwidgets/qgsrelationwidgetwrapper.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
#include "qgsrelationeditorwidget.h"
1919
#include "qgsattributeeditorcontext.h"
20+
#include "qgsproject.h"
2021

2122
#include <QWidget>
2223

@@ -35,7 +36,7 @@ QWidget* QgsRelationWidgetWrapper::createWidget( QWidget* parent )
3536
void QgsRelationWidgetWrapper::setFeature( const QgsFeature& feature )
3637
{
3738
if ( mWidget && mRelation.isValid() )
38-
mWidget->setRelationFeature( mRelation, feature );
39+
mWidget->setFeature( feature );
3940
}
4041

4142
void QgsRelationWidgetWrapper::initWidget( QWidget* editor )
@@ -71,6 +72,10 @@ void QgsRelationWidgetWrapper::initWidget( QWidget* editor )
7172
}
7273
while ( ctx );
7374

75+
QgsRelation nmrel = QgsProject::instance()->relationManager()->relation( config( "nm-rel" ).toString() );
76+
77+
w->setRelations( mRelation, nmrel );
78+
7479
mWidget = w;
7580
}
7681

‎src/gui/qgsattributeform.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,7 @@ QWidget* QgsAttributeForm::createWidgetFromDef( const QgsAttributeEditorElement
793793
}
794794
QWidget* spacer = new QWidget();
795795
spacer->setSizePolicy( QSizePolicy::Minimum, QSizePolicy::Preferred );
796-
gbLayout->addWidget( spacer, index, 0 );
796+
// gbLayout->addWidget( spacer, index, 0 );
797797

798798
labelText = QString::null;
799799
labelOnTop = true;

‎src/gui/qgscollapsiblegroupbox.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ void QgsCollapsibleGroupBoxBasic::init()
8989

9090
void QgsCollapsibleGroupBoxBasic::showEvent( QShowEvent * event )
9191
{
92-
//QgsDebugMsg( "Entered" );
92+
QgsDebugMsg( "Entered" );
9393
// initialise widget on first show event only
9494
if ( mShown )
9595
{

‎src/gui/qgsfeatureselectiondlg.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,9 @@ const QgsFeatureIds& QgsFeatureSelectionDlg::selectedFeatures()
4040
return mFeatureSelection->selectedFeaturesIds();
4141
}
4242

43+
void QgsFeatureSelectionDlg::setSelectedFeatures( const QgsFeatureIds& ids )
44+
{
45+
mFeatureSelection->setSelectedFeatures( ids );
46+
}
47+
4348

‎src/gui/qgsfeatureselectiondlg.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,19 @@ class GUI_EXPORT QgsFeatureSelectionDlg : public QDialog, private Ui::QgsFeature
2727
public:
2828
explicit QgsFeatureSelectionDlg( QgsVectorLayer* vl, QgsAttributeEditorContext &context, QWidget *parent = 0 );
2929

30+
/**
31+
* Get the selected features
32+
*
33+
* @return The selected feature ids
34+
*/
3035
const QgsFeatureIds& selectedFeatures();
3136

37+
/**
38+
* Set the selected features
39+
* @param ids The feature ids to select
40+
*/
41+
void setSelectedFeatures( const QgsFeatureIds& ids );
42+
3243
private:
3344
QgsGenericFeatureSelectionManager* mFeatureSelection;
3445
QgsVectorLayer* mVectorLayer;

‎src/gui/qgsrelationeditorwidget.cpp

Lines changed: 271 additions & 65 deletions
Large diffs are not rendered by default.

‎src/gui/qgsrelationeditorwidget.h

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class QgsVectorLayerTools;
3434
class GUI_EXPORT QgsRelationEditorWidget : public QgsCollapsibleGroupBox
3535
{
3636
Q_OBJECT
37-
Q_PROPERTY( QString qgisRelation READ qgisRelation WRITE setQgisRelation )
3837
Q_PROPERTY( QgsDualView::ViewMode viewMode READ viewMode WRITE setViewMode )
3938

4039
public:
@@ -49,25 +48,32 @@ class GUI_EXPORT QgsRelationEditorWidget : public QgsCollapsibleGroupBox
4948
//! Get the view mode for the dual view
5049
QgsDualView::ViewMode viewMode() {return mViewMode;}
5150

52-
//! Defines the relation ID (from project relations)
53-
//! @note use a widget's property to keep compatibility with using basic widget instead of QgsRelationEditorWidget
54-
void setQgisRelation( const QString& qgisRelationId );
55-
56-
//! Get the relation ID (from project relations)
57-
//! @note use a widget's property to keep compatibility with using basic widget instead of QgsRelationEditorWidget
58-
QString qgisRelation() const { return mRelationId; } //property( "qgisRelation" ).toString()
59-
60-
void setQgisNmRelation( const QString& qgisRelationId );
51+
void setRelationFeature( const QgsRelation& relation, const QgsFeature& feature );
6152

62-
QString qgisNmRelation() const { return mNmRelationId; }
53+
/**
54+
* Set the relation(s) for this widget
55+
* If only one relation is set, it will act as a simple 1:N relation widget
56+
* If both relations are set, it will act as an N:M relation widget
57+
* inserting and deleting entries on the intermediate table as required.
58+
*
59+
* @param relation Relation referencing the edited table
60+
* @param nmrelation Optional reference from the referencing table to a 3rd N:M table
61+
*/
62+
void setRelations( const QgsRelation& relation, const QgsRelation& nmrelation );
6363

64-
void setRelationFeature( const QgsRelation& relation, const QgsFeature& feature );
64+
void setFeature( const QgsFeature& feature );
6565

6666
void setEditorContext( const QgsAttributeEditorContext& context );
6767

68+
/**
69+
* The feature selection manager is responsible for the selected features
70+
* which are currently being edited.
71+
*/
72+
QgsIFeatureSelectionManager* featureSelectionManager();
73+
6874
private slots:
6975
void setViewMode( int mode ) {setViewMode( static_cast<QgsDualView::ViewMode>( mode ) );}
70-
void referencingLayerEditingToggled();
76+
void updateButtons();
7177

7278
void addFeature();
7379
void linkFeature();
@@ -78,14 +84,14 @@ class GUI_EXPORT QgsRelationEditorWidget : public QgsCollapsibleGroupBox
7884
void onCollapsedStateChanged( bool collapsed );
7985

8086
private:
87+
void updateUi();
88+
8189
QgsDualView* mDualView;
8290
QgsDualView::ViewMode mViewMode;
8391
QgsGenericFeatureSelectionManager* mFeatureSelectionMgr;
8492
QgsAttributeEditorContext mEditorContext;
8593
QgsRelation mRelation;
86-
QString mRelationId;
8794
QgsRelation mNmRelation;
88-
QString mNmRelationId;
8995
QgsFeature mFeature;
9096

9197
QToolButton* mToggleEditingButton;
@@ -99,7 +105,7 @@ class GUI_EXPORT QgsRelationEditorWidget : public QgsCollapsibleGroupBox
99105
QGridLayout* mRelationLayout;
100106
QButtonGroup* mViewModeButtonGroup;
101107

102-
bool mInitialized;
108+
bool mVisible;
103109
};
104110

105111
#endif // QGSRELATIONEDITOR_H

‎src/ui/qgsdualviewbase.ui

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</rect>
1212
</property>
1313
<property name="currentIndex">
14-
<number>0</number>
14+
<number>1</number>
1515
</property>
1616
<widget class="QWidget" name="mPageTableView">
1717
<layout class="QGridLayout" name="gridLayout_3">
@@ -158,7 +158,7 @@
158158
<rect>
159159
<x>0</x>
160160
<y>0</y>
161-
<width>230</width>
161+
<width>229</width>
162162
<height>503</height>
163163
</rect>
164164
</property>

‎tests/src/python/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ ENDIF (WITH_DESKTOP)
7979

8080
IF (ENABLE_PGTEST)
8181
ADD_PYTHON_TEST(PyQgsPostgresProvider test_provider_postgres.py)
82+
ADD_PYTHON_TEST(PyQgsRelationEditWidget test_qgsrelationeditwidget.py)
8283
ENDIF (ENABLE_PGTEST)
8384

8485
IF (WITH_APIDOC)
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# -*- coding: utf-8 -*-
2+
"""QGIS Unit tests for edit widgets.
3+
4+
.. note:: This program is free software; you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation; either version 2 of the License, or
7+
(at your option) any later version.
8+
"""
9+
__author__ = 'Matthias Kuhn'
10+
__date__ = '28/11/2015'
11+
__copyright__ = 'Copyright 2015, The QGIS Project'
12+
# This will get replaced with a git SHA1 when you do a git archive
13+
__revision__ = '$Format:%H$'
14+
15+
import qgis
16+
import os
17+
18+
from qgis.core import (
19+
QgsFeature,
20+
QgsGeometry,
21+
QgsPoint,
22+
QgsVectorLayer,
23+
NULL,
24+
QgsProject,
25+
QgsRelation,
26+
QgsMapLayerRegistry,
27+
QgsTransaction,
28+
QgsFeatureRequest
29+
)
30+
31+
from qgis.gui import (
32+
QgsEditorWidgetRegistry,
33+
QgsRelationWidgetWrapper,
34+
QgsAttributeEditorContext,
35+
QgsVectorLayerTools,
36+
QgsFeatureListView
37+
)
38+
39+
from PyQt.QtCore import (
40+
QTimer
41+
)
42+
43+
from PyQt.QtWidgets import (
44+
QWidget,
45+
QToolButton,
46+
QTableView,
47+
QListView
48+
)
49+
50+
from PyQt.QtGui import (
51+
QApplication
52+
)
53+
54+
from time import sleep
55+
56+
from utilities import (unitTestDataPath,
57+
getQgisTestApp,
58+
TestCase,
59+
unittest
60+
)
61+
QGISAPP, CANVAS, IFACE, PARENT = getQgisTestApp()
62+
63+
64+
class TestQgsTextEditWidget(TestCase):
65+
66+
@classmethod
67+
def setUpClass(cls):
68+
"""
69+
Setup the involved layers and relations for a n:m relation
70+
:return:
71+
"""
72+
QgsEditorWidgetRegistry.initEditors()
73+
cls.dbconn = u'dbname=\'qgis_test\' host=localhost port=5432 user=\'postgres\' password=\'postgres\''
74+
if 'QGIS_PGTEST_DB' in os.environ:
75+
cls.dbconn = os.environ['QGIS_PGTEST_DB']
76+
# Create test layer
77+
cls.vl_b = QgsVectorLayer(cls.dbconn + ' sslmode=disable key=\'pk\' table="qgis_test"."books" sql=', 'test', 'postgres')
78+
cls.vl_a = QgsVectorLayer(cls.dbconn + ' sslmode=disable key=\'pk\' table="qgis_test"."authors" sql=', 'test', 'postgres')
79+
cls.vl_link = QgsVectorLayer(cls.dbconn + ' sslmode=disable key=\'pk\' table="qgis_test"."books_authors" sql=', 'test', 'postgres')
80+
81+
QgsMapLayerRegistry.instance().addMapLayer(cls.vl_b)
82+
QgsMapLayerRegistry.instance().addMapLayer(cls.vl_a)
83+
QgsMapLayerRegistry.instance().addMapLayer(cls.vl_link)
84+
85+
relMgr = QgsProject.instance().relationManager()
86+
87+
cls.rel_a = QgsRelation()
88+
cls.rel_a.setReferencingLayer(cls.vl_link.id())
89+
cls.rel_a.setReferencedLayer(cls.vl_a.id())
90+
cls.rel_a.addFieldPair('fk_author', 'pk')
91+
cls.rel_a.setRelationId('rel_a')
92+
assert(cls.rel_a.isValid())
93+
relMgr.addRelation(cls.rel_a)
94+
95+
cls.rel_b = QgsRelation()
96+
cls.rel_b.setReferencingLayer(cls.vl_link.id())
97+
cls.rel_b.setReferencedLayer(cls.vl_b.id())
98+
cls.rel_b.addFieldPair('fk_book', 'pk')
99+
cls.rel_b.setRelationId('rel_b')
100+
assert(cls.rel_b.isValid())
101+
relMgr.addRelation(cls.rel_b)
102+
103+
# Our mock QgsVectorLayerTools, that allow to inject data where user input is expected
104+
cls.vltools = VlTools()
105+
106+
assert(cls.vl_a.isValid())
107+
assert(cls.vl_b.isValid())
108+
assert(cls.vl_link.isValid())
109+
110+
def setUp(self):
111+
self.startTransaction()
112+
113+
def tearDown(self):
114+
self.rollbackTransaction()
115+
116+
def test_delete_feature(self):
117+
"""
118+
Check if a feature can be deleted properly
119+
"""
120+
self.createWrapper(self.vl_a, '"name"=\'Erich Gamma\'')
121+
122+
self.assertEquals(self.table_view.model().rowCount(), 1)
123+
124+
self.assertEquals(1, len([f for f in self.vl_b.getFeatures()]))
125+
126+
fid = self.vl_b.getFeatures(QgsFeatureRequest().setFilterExpression('"name"=\'Design Patterns. Elements of Reusable Object-Oriented Software\'')).next().id()
127+
128+
self.widget.featureSelectionManager().select([fid])
129+
130+
btn = self.widget.findChild(QToolButton, 'mDeleteFeatureButton')
131+
btn.click()
132+
133+
# This is the important check that the feature is deleted
134+
self.assertEquals(0, len([f for f in self.vl_b.getFeatures()]))
135+
136+
# This is actually more checking that the database on delete action is properly set on the relation
137+
self.assertEquals(0, len([f for f in self.vl_link.getFeatures()]))
138+
139+
self.assertEquals(self.table_view.model().rowCount(), 0)
140+
141+
def test_list(self):
142+
"""
143+
Simple check if several related items are shown
144+
"""
145+
wrapper = self.createWrapper(self.vl_b)
146+
147+
self.assertEquals(self.table_view.model().rowCount(), 4)
148+
149+
def test_add_feature(self):
150+
"""
151+
Check if a new related feature is added
152+
"""
153+
self.createWrapper(self.vl_a, '"name"=\'Douglas Adams\'')
154+
155+
self.assertEquals(self.table_view.model().rowCount(), 0)
156+
157+
self.vltools.setValues([None, 'The Hitchhiker\'s Guide to the Galaxy'])
158+
btn = self.widget.findChild(QToolButton, 'mAddFeatureButton')
159+
btn.click()
160+
161+
# Book entry has been created
162+
self.assertEquals(2, len([f for f in self.vl_b.getFeatures()]))
163+
164+
# Link entry has been created
165+
self.assertEquals(5, len([f for f in self.vl_link.getFeatures()]))
166+
167+
self.assertEquals(self.table_view.model().rowCount(), 1)
168+
169+
def test_link_feature(self):
170+
"""
171+
Check if an existing feature can be linked
172+
"""
173+
wrapper = self.createWrapper(self.vl_a, '"name"=\'Douglas Adams\'')
174+
175+
f = QgsFeature(self.vl_b.fields())
176+
f.setAttributes([self.vl_b.dataProvider().defaultValue(0), 'The Hitchhiker\'s Guide to the Galaxy'])
177+
self.vl_b.addFeature(f)
178+
179+
def choose_linked_feature():
180+
dlg = QApplication.activeModalWidget()
181+
dlg.setSelectedFeatures([f.id()])
182+
dlg.accept()
183+
184+
btn = self.widget.findChild(QToolButton, 'mLinkFeatureButton')
185+
186+
timer = QTimer()
187+
timer.setSingleShot(True)
188+
timer.setInterval(0) # will run in the event loop as soon as it's processed when the dialog is opened
189+
timer.timeout.connect(choose_linked_feature)
190+
timer.start()
191+
192+
btn.click()
193+
# magically the above code selects the feature here...
194+
195+
link_feature = self.vl_link.getFeatures(QgsFeatureRequest().setFilterExpression('"fk_book"={}'.format(f[0]))).next()
196+
self.assertIsNotNone(link_feature[0])
197+
198+
self.assertEquals(self.table_view.model().rowCount(), 1)
199+
200+
def test_unlink_feature(self):
201+
"""
202+
Check if a linked feature can be unlinked
203+
"""
204+
wrapper = self.createWrapper(self.vl_b)
205+
wdg = wrapper.widget()
206+
207+
# All authors are listed
208+
self.assertEquals(self.table_view.model().rowCount(), 4)
209+
210+
it = self.vl_a.getFeatures(
211+
QgsFeatureRequest().setFilterExpression('"name" IN (\'Richard Helm\', \'Ralph Johnson\')'))
212+
213+
self.widget.featureSelectionManager().select([f.id() for f in it])
214+
215+
btn = self.widget.findChild(QToolButton, 'mUnlinkFeatureButton')
216+
btn.click()
217+
218+
# This is actually more checking that the database on delete action is properly set on the relation
219+
self.assertEquals(2, len([f for f in self.vl_link.getFeatures()]))
220+
221+
self.assertEquals(2, self.table_view.model().rowCount())
222+
223+
def startTransaction(self):
224+
"""
225+
Start a new transaction and set all layers into transaction mode.
226+
227+
:return: None
228+
"""
229+
lyrs = [self.vl_a, self.vl_b, self.vl_link]
230+
231+
self.transaction = QgsTransaction.create([l.id() for l in lyrs])
232+
self.transaction.begin()
233+
for l in lyrs:
234+
l.startEditing()
235+
236+
def rollbackTransaction(self):
237+
"""
238+
Rollback all changes done in this transaction.
239+
We always rollback and never commit to have the database in a pristine
240+
state at the end of each test.
241+
242+
:return: None
243+
"""
244+
lyrs = [self.vl_a, self.vl_b, self.vl_link]
245+
for l in lyrs:
246+
l.commitChanges()
247+
self.transaction.rollback()
248+
249+
def createWrapper(self, layer, filter=None):
250+
"""
251+
Basic setup of a relation widget wrapper.
252+
Will create a new wrapper and set its feature to the one and only book
253+
in the table.
254+
It will also assign some instance variables to help
255+
256+
* self.widget The created widget
257+
* self.table_view The table view of the widget
258+
259+
:return: The created wrapper
260+
"""
261+
if layer == self.vl_b:
262+
relation = self.rel_b
263+
nmrel = self.rel_a
264+
else:
265+
relation = self.rel_a
266+
nmrel = self.rel_b
267+
268+
parent = QWidget()
269+
self.wrapper = QgsRelationWidgetWrapper(layer, relation)
270+
self.wrapper.setConfig({'nm-rel': nmrel.id()})
271+
context = QgsAttributeEditorContext()
272+
context.setVectorLayerTools(self.vltools)
273+
self.wrapper.setContext(context)
274+
275+
self.widget = self.wrapper.widget()
276+
self.widget.show()
277+
278+
request = QgsFeatureRequest()
279+
if filter:
280+
request.setFilterExpression(filter)
281+
book = layer.getFeatures(request).next()
282+
self.wrapper.setFeature(book)
283+
284+
self.table_view = self.widget.findChild(QTableView)
285+
return self.wrapper
286+
287+
288+
class VlTools(QgsVectorLayerTools):
289+
290+
"""
291+
Mock the QgsVectorLayerTools
292+
Since we don't have a user on the test server to input this data for us, we can just use this.
293+
"""
294+
295+
def setValues(self, values):
296+
"""
297+
Set the values for the next feature to insert
298+
:param values: An array of values that shall be used for the next inserted record
299+
:return: None
300+
"""
301+
self.values = values
302+
303+
def addFeature(self, layer, defaultValues, defaultGeometry):
304+
"""
305+
Overrides the addFeature method
306+
:param layer: vector layer
307+
:param defaultValues: some default values that may be provided by QGIS
308+
:param defaultGeometry: a default geometry that may be provided by QGIS
309+
:return: tuple(ok, f) where ok is if the layer added the feature and f is the added feature
310+
"""
311+
values = list()
312+
for i, v in enumerate(self.values):
313+
if v:
314+
values.append(v)
315+
else:
316+
values.append(layer.dataProvider().defaultValue(i))
317+
f = QgsFeature(layer.fields())
318+
f.setAttributes(self.values)
319+
f.setGeometry(defaultGeometry)
320+
ok = layer.addFeature(f)
321+
322+
return ok, f
323+
324+
def startEditing(self, layer):
325+
pass
326+
327+
def stopEditing(self, layer, allowCancel):
328+
pass
329+
330+
def saveEdits(self, layer):
331+
pass
332+
333+
if __name__ == '__main__':
334+
unittest.main()

‎tests/testdata/provider/reltests.sql

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-- Table: qgis_test.authors
2+
3+
-- DROP TABLE qgis_test.authors;
4+
5+
CREATE TABLE qgis_test.authors
6+
(
7+
pk serial NOT NULL,
8+
name character varying(255),
9+
CONSTRAINT authors_pkey PRIMARY KEY (pk),
10+
CONSTRAINT authors_name_key UNIQUE (name)
11+
);
12+
13+
-- Table: qgis_test.books
14+
15+
-- DROP TABLE qgis_test.books;
16+
17+
CREATE TABLE qgis_test.books
18+
(
19+
pk serial NOT NULL,
20+
name character varying(255),
21+
CONSTRAINT books_pkey PRIMARY KEY (pk),
22+
CONSTRAINT books_name_key UNIQUE (name)
23+
);
24+
25+
-- Table: qgis_test.books_authors
26+
27+
-- DROP TABLE qgis_test.books_authors;
28+
29+
CREATE TABLE qgis_test.books_authors
30+
(
31+
fk_book integer NOT NULL,
32+
fk_author integer NOT NULL,
33+
CONSTRAINT books_authors_pkey PRIMARY KEY (fk_book, fk_author),
34+
CONSTRAINT books_authors_fk_author_fkey FOREIGN KEY (fk_author)
35+
REFERENCES qgis_test.authors (pk) MATCH SIMPLE
36+
ON UPDATE NO ACTION ON DELETE CASCADE,
37+
CONSTRAINT books_authors_fk_book_fkey FOREIGN KEY (fk_book)
38+
REFERENCES qgis_test.books (pk) MATCH SIMPLE
39+
ON UPDATE NO ACTION ON DELETE CASCADE
40+
);
41+
42+
INSERT INTO qgis_test.authors(name)
43+
VALUES
44+
('Erich Gamma'),
45+
('Richard Helm'),
46+
('Ralph Johnson'),
47+
('John Vlissides'),
48+
('Douglas Adams'),
49+
('Ken Follett'),
50+
('Gabriel García Márquez');
51+
52+
INSERT INTO qgis_test.books(name)
53+
VALUES
54+
('Design Patterns. Elements of Reusable Object-Oriented Software');
55+
56+
INSERT INTO qgis_test.books_authors(fk_book, fk_author)
57+
VALUES
58+
(1, 1),
59+
(1, 2),
60+
(1, 3),
61+
(1, 4);

0 commit comments

Comments
 (0)
Please sign in to comment.