Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions Mergin/field_filtering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import json
from enum import Enum
from typing import Optional, Union, List

from qgis.core import QgsMapLayer, QgsProviderRegistry
from qgis.PyQt.QtCore import Qt, QAbstractListModel, QModelIndex, pyqtSignal
from qgis.PyQt.QtWidgets import QListView
from qgis.PyQt.QtGui import QMouseEvent


class FieldFilterType(str, Enum):
SINGLE_SELECT = "Single select"
MULTI_SELECT = "Multi select"
CHECKBOX = "Checkbox"
DATE = "Date"
NUMBER = "Number"
TEXT = "Text"


def excluded_filtering_providers() -> List[str]:
"""Get list of providers to exclude from layer selection in field filter settings."""
excluded_providers = QgsProviderRegistry.instance().providerList()
excluded_providers.remove("ogr")
excluded_providers.remove("postgres")
return excluded_providers


def field_filters_to_json(filters: List["FieldFilter"]) -> str:
"""Serialize a list of FieldFilter objects to a JSON string."""
return json.dumps([f.to_json() for f in filters])


def field_filters_from_json(data: str) -> List["FieldFilter"]:
"""Deserialize a JSON string into a list of FieldFilter objects."""
return [FieldFilter.from_json(item) for item in json.loads(data)]


class FieldFilter:

def __init__(self, layer: QgsMapLayer, field_name: str, filter_type: FieldFilterType, filter_name: str):
provider = layer.dataProvider()
self.layer_id = layer.id()
self.provider = provider.name() if provider else ""
self.field_name = field_name
self.filter_type = filter_type
self.filter_name = filter_name
self.sql_expression = ""

@classmethod
def from_json(cls, data: dict) -> "FieldFilter":
"""Create a FieldFilter instance from a JSON dictionary"""
f = object.__new__(cls)
f.layer_id = data["layer_id"]
f.provider = data.get("provider", "")
f.field_name = data["field_name"]
f.filter_type = FieldFilterType(data["filter_type"])
f.filter_name = data["filter_name"]
f.sql_expression = data.get("sql_expression", "")
return f

def to_json(self) -> dict:
"""Convert the object to a JSON-serializable dictionary"""
return {
"layer_id": self.layer_id,
"provider": self.provider,
"field_name": self.field_name,
"filter_type": self.filter_type.value,
"filter_name": self.filter_name,
"sql_expression": self.sql_expression,
}


class FieldFilterModel(QAbstractListModel):
"""Model to manage a list of FieldFilter objects, providing methods to add, remove, and reorder filters."""

def __init__(self, parent=None):
super().__init__(parent)
self._filters: list[FieldFilter] = []

def rowCount(self, parent=QModelIndex()) -> int:
return len(self._filters)

def data(self, index: QModelIndex, role=Qt.ItemDataRole.UserRole) -> Union[str, FieldFilter, None]:
if not index.isValid() or index.row() >= len(self._filters):
return None
f = self._filters[index.row()]
if role == Qt.ItemDataRole.DisplayRole:
return f.filter_name
elif role == Qt.ItemDataRole.UserRole:
return f
return None

def add_filter(self, field_filter: FieldFilter):
"""Add filter to the model, notifying views of the change."""
self.beginInsertRows(QModelIndex(), len(self._filters), len(self._filters))
self._filters.append(field_filter)
self.endInsertRows()

def remove_filter(self, row: int):
"""Remove filter at the specified row, notifying views of the change."""
if 0 <= row < len(self._filters):
self.beginRemoveRows(QModelIndex(), row, row)
self._filters.pop(row)
self.endRemoveRows()

def move_filter(self, row: int, offset: int) -> None:
"""Move filter at the specified row by the given offset, notifying views of the change."""
target = row + offset
if 0 <= row < len(self._filters) and 0 <= target < len(self._filters):
self._filters[row], self._filters[target] = self._filters[target], self._filters[row]
top, bottom = min(row, target), max(row, target)
self.dataChanged.emit(self.index(top), self.index(bottom))

def filter_names(self) -> List[str]:
"""Get list of filter names for all filters in the model."""
return [f.filter_name for f in self._filters]

def to_json(self) -> str:
"""Serialize the list of filters in the model to a JSON string."""
return field_filters_to_json(self._filters)

def load_from_json(self, data: str) -> None:
"""Load filters from a JSON string, replacing existing filters and notifying views of the change."""
self.beginResetModel()
self._filters = field_filters_from_json(data)
self.endResetModel()


class DeselectableListView(QListView):
"""QListView that clears selection when clicking outside items or on the already-selected item."""

selectionCleared = pyqtSignal(QModelIndex, QModelIndex)

def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
if event:
index = self.indexAt(event.pos())
if not index.isValid() or index == self.currentIndex():
self.blockSignals(True)
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.blockSignals(False)
self.selectionCleared.emit(QModelIndex(), QModelIndex())
return

super().mousePressEvent(event)
162 changes: 159 additions & 3 deletions Mergin/project_settings_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import json
import os
import typing

from qgis.PyQt import uic
from qgis.PyQt.QtGui import QIcon, QColor
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox
from qgis.PyQt.QtCore import Qt, QModelIndex
from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QGroupBox, QComboBox
from qgis.core import (
QgsProject,
QgsExpressionContext,
Expand All @@ -16,8 +17,15 @@
QgsFeatureRequest,
QgsExpression,
QgsMapLayer,
QgsFieldProxyModel,
)
from qgis.gui import (
QgsOptionsWidgetFactory,
QgsOptionsPageWidget,
QgsColorButton,
QgsMapLayerComboBox,
QgsFieldComboBox,
)
from qgis.gui import QgsOptionsWidgetFactory, QgsOptionsPageWidget, QgsColorButton
from .attachment_fields_model import AttachmentFieldsModel
from .utils import (
mm_symbol_path,
Expand All @@ -33,6 +41,13 @@
escape_html_minimal,
sanitize_path,
)
from .field_filtering import (
FieldFilterType,
FieldFilter,
FieldFilterModel,
DeselectableListView,
excluded_filtering_providers,
)

ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui")
ProjectConfigUiWidget, _ = uic.loadUiType(ui_file)
Expand All @@ -53,6 +68,13 @@ def createWidget(self, parent):


class ProjectConfigWidget(ProjectConfigUiWidget, QgsOptionsPageWidget):

cmb_filter_type: QComboBox
cmb_filter_layer: QgsMapLayerComboBox
cmb_filter_field: QgsFieldComboBox
groupBox_filters_list: QGroupBox
groupBox_filter_detail: QGroupBox

def __init__(self, parent=None):
QgsOptionsPageWidget.__init__(self, parent)
self.setupUi(self)
Expand Down Expand Up @@ -119,6 +141,39 @@ def __init__(self, parent=None):
idx = self.cmb_sort_method.findData(mode) if ok else 1
self.cmb_sort_method.setCurrentIndex(idx)

self.filters_model = FieldFilterModel()
self.btn_add_filter.clicked.connect(self.on_add_filter_clicked)
self.btn_remove_filter.clicked.connect(self.on_remove_filter_clicked)
self.btn_move_filter_up.clicked.connect(self.on_move_filter_up_clicked)
self.btn_move_filter_down.clicked.connect(self.on_move_filter_down_clicked)

self.lst_filters = DeselectableListView(self)
self.groupBox_filters_list.layout().insertWidget(0, self.lst_filters)
self.lst_filters.setModel(self.filters_model)
self.lst_filters.selectionCleared.connect(self.on_filter_selection_changed)
self.lst_filters.selectionModel().selectionChanged.connect(self._update_filter_buttons)
self.lst_filters.selectionModel().currentChanged.connect(self.on_filter_selection_changed)

enabled, _ = QgsProject.instance().readBoolEntry("Mergin", "Filtering/Enabled", False)
self.chk_filtering_enabled.setChecked(enabled)
self.groupBox_filters_list.setEnabled(enabled)
self.groupBox_filter_detail.setEnabled(enabled)
self.chk_filtering_enabled.stateChanged.connect(self.on_filtering_state_changed)

filters_json, _ = QgsProject.instance().readEntry("Mergin", "Filtering/Filters", "[]")
self.filters_model.load_from_json(filters_json)

self.cmb_filter_layer.setAllowEmptyLayer(True)
self.cmb_filter_layer.setExcludedProviders(excluded_filtering_providers())
self.cmb_filter_layer.layerChanged.connect(self.on_filter_layer_fields_changed)

for f in FieldFilterType:
self.cmb_filter_type.addItem(f.value, f)
self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_layer_fields_changed)

self._update_filter_buttons()
self.on_filter_layer_fields_changed()

self.local_project_dir = mergin_project_local_path()

if self.local_project_dir:
Expand Down Expand Up @@ -351,6 +406,9 @@ def apply(self):
self.setup_tracking()
self.setup_map_sketches()

QgsProject.instance().writeEntry("Mergin", "Filtering/Enabled", self.chk_filtering_enabled.isChecked())
QgsProject.instance().writeEntry("Mergin", "Filtering/Filters", self.filters_model.to_json())

def colors_change_state(self) -> None:
"""
Enable/disable color buttons based on the state of the map sketches checkbox.
Expand All @@ -359,3 +417,101 @@ def colors_change_state(self) -> None:
item = self.mColorsHorizontalLayout.itemAt(i).widget()
if isinstance(item, QgsColorButton):
item.setEnabled(self.chk_map_sketches_enabled.isChecked())

def on_filtering_state_changed(self, state: Qt.CheckState) -> None:
"""
Enable/disable filtering options based on the state of the filtering checkbox.
"""
if state == Qt.CheckState.Checked:
self.groupBox_filters_list.setEnabled(True)
self.groupBox_filter_detail.setEnabled(True)
else:
self.groupBox_filters_list.setEnabled(False)
self.groupBox_filter_detail.setEnabled(False)

def on_add_filter_clicked(self) -> None:
layer = self.cmb_filter_layer.currentLayer()
field_name = self.cmb_filter_field.currentField()
filter_type = self.cmb_filter_type.currentData()
filter_name = self.edit_filter_title.text().strip()

if not layer or not layer.isValid():
return
if not field_name:
return
if not filter_name:
return

self.filters_model.add_filter(
FieldFilter(
layer=layer,
field_name=field_name,
filter_type=filter_type,
filter_name=filter_name,
)
)

def on_filter_selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
field_filter: typing.Optional[FieldFilter] = self.filters_model.data(current, Qt.ItemDataRole.UserRole)
if field_filter is None:
self.cmb_filter_layer.setLayer(None)
self.cmb_filter_field.setLayer(None)
self.cmb_filter_type.setCurrentIndex(0)
self.edit_filter_title.clear()
return

layer = QgsProject.instance().mapLayer(field_filter.layer_id)

self.cmb_filter_layer.blockSignals(True)
self.cmb_filter_layer.setLayer(layer)
self.cmb_filter_layer.blockSignals(False)

self.cmb_filter_field.blockSignals(True)
self.cmb_filter_field.setLayer(layer)
self.cmb_filter_field.setField(field_filter.field_name)
self.cmb_filter_field.blockSignals(False)

idx = self.cmb_filter_type.findData(field_filter.filter_type)
self.cmb_filter_type.blockSignals(True)
self.cmb_filter_type.setCurrentIndex(idx)
self.cmb_filter_type.blockSignals(False)

self.edit_filter_title.setText(field_filter.filter_name)

def _update_filter_buttons(self) -> None:
has_selection = self.lst_filters.selectionModel().hasSelection()
self.btn_remove_filter.setEnabled(has_selection)
self.btn_move_filter_up.setEnabled(has_selection)
self.btn_move_filter_down.setEnabled(has_selection)

def on_remove_filter_clicked(self) -> None:
row = self.lst_filters.currentIndex().row()
self.filters_model.remove_filter(row)

def on_move_filter_up_clicked(self) -> None:
row = self.lst_filters.currentIndex().row()
self.filters_model.move_filter(row, -1)
self.lst_filters.setCurrentIndex(self.filters_model.index(row - 1))

def on_move_filter_down_clicked(self) -> None:
row = self.lst_filters.currentIndex().row()
self.filters_model.move_filter(row, 1)
self.lst_filters.setCurrentIndex(self.filters_model.index(row + 1))

def on_filter_layer_fields_changed(self) -> None:
"""
Update the fields in the filter field combo box based on the selected layer.
"""
layer = self.cmb_filter_layer.currentLayer()
self.cmb_filter_field.setLayer(layer)
filter_type = self.cmb_filter_type.currentData()
if filter_type in (FieldFilterType.SINGLE_SELECT, FieldFilterType.MULTI_SELECT):
self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes)
elif filter_type == FieldFilterType.CHECKBOX:
self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Boolean)
elif filter_type == FieldFilterType.DATE:
self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Date)
elif filter_type in (FieldFilterType.NUMBER, FieldFilterType.TEXT):
self.cmb_filter_field.setFilters(
QgsFieldProxyModel.Filter(QgsFieldProxyModel.Filter.Numeric | QgsFieldProxyModel.Filter.String)
)
Loading
Loading