[GIS] Automatically updating user-edited features in QGIS plugin

editingpythonqgisvector

I am working on a plugin for QGIS (2.12). It uses data stored in an Spatialite database. When the user creates a new feature, the plugin should:

  • update the feature attribute table (the popup form is disabled)
  • update another (non-geometry) table in the database
  • if the polygon overlaps any existing polygons:
    • update the polygon geometry using the difference method

The modifications do not need any user input, and can happen in 'real-time' or following a commitChanges(), but the user should remain in (or be returned to) an editing session.

What is the best strategy for this? Should I use the provider, the vector layer functions, the iface.vectorLayerTools?


So far, I have not had much success. I have a class that is created on the layer.editingStarted() signal that listens for changes to geometry. It includes the following:

def __init__(self, iface, vlayer, db):
    self.vlayer = vlayer
    self.edit_buffer = vlayer.editBuffer()

def connect_signals
    self.edit_buffer.featureAdded.connect(self.feature_added)
    self.edit_buffer.geometryChanged.connect(self.geometry_changed)
    self.vlayer.beforeCommitChanges.connect(self.disconnect_eb_signals)
    self.vlayer.committedFeaturesAdded.connect(self.committed_add)

I first tried to use the edit buffer's featureAdded() signal e.g.

def feature_added(self, fid):
    self.vlayer.beginEditCommand('Update attributes')
    self.vlayer.changeAttributeValue(fid, 1, 'new_value')
    self.vlayer.endEditCommand()

Using the temporary fids given to the pre-commit features prevented making the changes to the other tables, which needed to reference them. Also, this method caused QGIS to crash/segmentation fault if the Undo command was used (on Linux, and with Undo, Redo on Windows).

Is this a bug, or am I just approaching this in the wrong way?

I have also tried using the commitedFeaturesAdded() signal. This solved the undo/redo issues and allowed me to update the attribute table directly in the database via SQL commands, after the feature was created with the final fid. However, now when I try to change the geometry and check for intersection/overlap again I get stuck in a loop of newly committed features.

Finally, I tried calculating the modified geometry, exporting as Wkt and using GeomFromText to update the vlayer table directly. In this case, the SQL is run without error, but the geometry doesn't seem to be updated.

Best Answer

I have found a way to do this. Changes to the feature geometry (e.g. removing overlaps) and attributes are made to committed new features, only after the committedFeaturesAdded signal has fired. Signals from the EditBuffer can be used to trigger messages but not database changes.

Where modifications fail, it is easier to delete the user feature and add a new one with the modified values.

I have posted code below that form a working example.

"""minimum worked example for changing attributes and geometries.  
to test, set appropriate database and layer names then save as
feature_modifier.py.  You can then type
`import feature_modifier as fm` into console, then run
`fm = feature_modifier.FeatureModifier()`.  
Begin editing and watch signals fire."""

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from qgis.core import *
from qgis.gui import *


class FeatureModifier(object):
    """Modifies features automatically when user edits are saved."""
    def __init__(self, iface):
        self.iface = iface
        self.load_layer()
        self.connect_signals()

    def load_layer(self):
        uri = QgsDataSourceURI()
        db_path = '/path/to/database.sqlite'
        uri.setDatabase(db_path)
        uri.setDataSource('', 'mylayer', 'geometry')
        vlayer = QgsVectorLayer(uri.uri(), 'mylayer', 'spatialite')
        vlayer.setCrs(QgsCoordinateReferenceSystem(27700,
                      QgsCoordinateReferenceSystem.EpsgCrsId), False)
        QgsMapLayerRegistry.instance().addMapLayer(vlayer)
        self.vlayer = vlayer

    def connect_signals(self):
        self.vlayer.editingStarted.connect(self.editing_started)
        self.vlayer.editingStopped.connect(self.editing_stopped)

    def editing_started(self):
        print('Editing started')
        # Disable attributes dialog
        QSettings().setValue(
            '/qgis/digitizing/disable_enter_attribute_values_dialog', True)
        self.edit_handler = EditHandler(self.vlayer)

    def editing_stopped(self):
        print('Editing stopped')
        self.edit_handler = None
        # Re-enable attributes dialog
        QSettings().setValue(
            '/qgis/digitizing/disable_enter_attribute_values_dialog', False)
        if self.vlayer.isEditable() is True:
            # Rolling back changes ends destroys geometry_handler class but
            # layer remains editable.  In this case, recreate it.
            self.editing_started()

    def clean_up(self):
        QgsMapLayerRegistry.instance().removeMapLayer(self.vlayer.id())
        self.iface.mapCanvas().clearCache()
        self.iface.mapCanvas().refresh()


class EditHandler(object):
    """
    Listens for committed feature additions and changes feature attributes.
    """
    def __init__(self, vlayer):
        self.vlayer = vlayer
        self.connect_committed_signals()

    def connect_committed_signals(self):
        """
        Connect signals for editing events
        """
        self.vlayer.committedFeaturesAdded.connect(self.committed_adds)
        self.vlayer.committedGeometriesChanges.connect(self.committed_changes)
        self.vlayer.committedFeaturesRemoved.connect(self.committed_deletes)

    def disconnect_committed_signals(self):
        """
        Disconnect signals for editing events
        """
        self.vlayer.committedFeaturesAdded.disconnect()
        self.vlayer.committedGeometriesChanges.disconnect()
        self.vlayer.committedFeaturesRemoved.disconnect()

    def committed_adds(self, layer_id, added_features):
        print('Committed features added to layer {}:'.format(layer_id))
        for feature in added_features:
            print(feature.id())
            self.replace_feature(feature.id())

    def committed_changes(self, layer_id, changed_geometries):
        print('Committed geometries changed in layer {}:'.format(layer_id))
        for fid in changed_geometries.keys():
            print(fid)
            self.update_geometry(fid, changed_geometries[fid])

    def committed_deletes(self, layer_id, deleted_fids):
        print('Committed features deleted from layer {}:'.format(layer_id))
        for fid in deleted_fids:
            print(fid)

    def replace_feature(self, fid):
        """
        Replace feature with customised geometry and attributes.
        """
        print('Replacing feature {} here.'.format(fid))
        feature = self.vlayer.getFeatures(
            QgsFeatureRequest().setFilterFid(fid)).next()
        geometry = feature.geometry()

        # Create new feature
        new_feature = QgsFeature(self.vlayer.pendingFields())
        geometry.translate(0, 50)  # Modify original geometry
        new_feature.setGeometry(geometry)
        new_feature.setAttribute('symbol', 10)  # Customise attributes

        # Update layer by removing old and adding new
        result = self.vlayer.dataProvider().deleteFeatures([fid])
        result, new_features = self.vlayer.dataProvider().addFeatures(
                [new_feature])
        for f in new_features:
            print('Replacement feature {} added'.format(f.id()))

    def update_geometry(self, fid, geometry):
        """
        Update the geometry of a feature, e.g. jump 100 m east.
        """
        geometry.translate(100, 0)
        self.vlayer.dataProvider().changeGeometryValues({fid: geometry})
Related Question