Skip to content

Commit 3992fcb

Browse files
authored
Kaavakohteiden ja kaavan muokkaaminen (#112)
* add plan feature editing - add a custom QgsMapTool for selecting a plan feature to modify - construct PlanFeatureForm from an existing feature - update feature attributes, including regulation group associations - fix duplicate PlanManager creation in plugin.py * add editing for plan layer - add toolbar action for editing plan attributes - modify plan model to allow Nones for all attributes - add set_value methods for CodeComboBoxes * optimize feature requests * removed plan regulation type selection widget
1 parent 874f4c3 commit 3992fcb

14 files changed

+498
-155
lines changed

arho_feature_template/core/models.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -331,14 +331,14 @@ def from_config_data(cls, data: dict) -> PlanFeature:
331331

332332
@dataclass
333333
class Plan:
334-
name: str
335-
description: str | None
336-
plan_type_id: str
337-
lifecycle_status_id: str
338-
record_number: str | None
339-
matter_management_identifier: str | None
340-
permanent_plan_identifier: str | None
341-
producers_plan_identifier: str | None
334+
name: str | None = None
335+
description: str | None = None
336+
plan_type_id: str | None = None
337+
lifecycle_status_id: str | None = None
338+
record_number: str | None = None
339+
matter_management_identifier: str | None = None
340+
permanent_plan_identifier: str | None = None
341+
producers_plan_identifier: str | None = None
342342
organisation_id: str | None = None
343343
general_regulations: list[RegulationGroup] = field(default_factory=list)
344344
geom: QgsGeometry | None = None

arho_feature_template/core/plan_manager.py

+107-38
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from arho_feature_template.core.lambda_service import LambdaService
1212
from arho_feature_template.core.models import (
1313
FeatureTemplateLibrary,
14+
Plan,
1415
PlanFeature,
1516
RegulationGroupCategory,
1617
RegulationGroupLibrary,
@@ -22,6 +23,7 @@
2223
from arho_feature_template.gui.dialogs.plan_feature_form import PlanFeatureForm
2324
from arho_feature_template.gui.dialogs.serialize_plan import SerializePlan
2425
from arho_feature_template.gui.docks.new_feature_dock import NewFeatureDock
26+
from arho_feature_template.gui.tools.inspect_plan_features_tool import InspectPlanFeatures
2527
from arho_feature_template.project.layers.code_layers import PlanRegulationGroupTypeLayer
2628
from arho_feature_template.project.layers.plan_layers import (
2729
LandUseAreaLayer,
@@ -49,16 +51,16 @@
4951
if TYPE_CHECKING:
5052
from qgis.core import QgsFeature
5153

52-
from arho_feature_template.core.models import Plan, Regulation, RegulationGroup
54+
from arho_feature_template.core.models import Regulation, RegulationGroup
5355

5456
logger = logging.getLogger(__name__)
5557

5658
FEATURE_LAYER_NAME_TO_CLASS_MAP: dict[str, type[PlanFeatureLayer]] = {
5759
LandUsePointLayer.name: LandUsePointLayer,
58-
OtherAreaLayer.name: OtherAreaLayer,
5960
OtherPointLayer.name: OtherPointLayer,
60-
LandUseAreaLayer.name: LandUseAreaLayer,
6161
LineLayer.name: LineLayer,
62+
OtherAreaLayer.name: OtherAreaLayer,
63+
LandUseAreaLayer.name: LandUseAreaLayer,
6264
}
6365

6466

@@ -95,6 +97,19 @@ def __init__(self):
9597
self.feature_digitize_map_tool = None
9698
self.initialize_feature_digitize_map_tool()
9799

100+
# Initialize plan feature inspect tool
101+
self.inspect_plan_feature_tool = InspectPlanFeatures(
102+
iface.mapCanvas(), list(FEATURE_LAYER_NAME_TO_CLASS_MAP.values())
103+
)
104+
self.inspect_plan_feature_tool.edit_feature_requested.connect(self.edit_plan_feature)
105+
106+
def toggle_identify_plan_features(self, activate: bool): # noqa: FBT001
107+
if activate:
108+
self.previous_map_tool = iface.mapCanvas().mapTool()
109+
iface.mapCanvas().setMapTool(self.inspect_plan_feature_tool)
110+
else:
111+
iface.mapCanvas().setMapTool(self.previous_map_tool)
112+
98113
def initialize_feature_digitize_map_tool(self, layer: QgsVectorLayer | None = None):
99114
# Get matcing capture mode for given layer
100115
if layer is None:
@@ -140,6 +155,22 @@ def add_new_plan(self):
140155
self.digitize_map_tool.setLayer(plan_layer)
141156
iface.mapCanvas().setMapTool(self.digitize_map_tool)
142157

158+
def edit_plan(self):
159+
plan_layer = PlanLayer.get_from_project()
160+
if not plan_layer:
161+
return
162+
163+
active_plan_id = QgsExpressionContextUtils.projectScope(QgsProject.instance()).variable("active_plan_id")
164+
feature = PlanLayer.get_feature_by_id(active_plan_id, no_geometries=False)
165+
if feature is None:
166+
iface.messageBar().pushWarning("", "No active/open plan found!")
167+
return
168+
plan_model = PlanLayer.model_from_feature(feature)
169+
170+
attribute_form = PlanAttributeForm(plan_model, self.regulation_group_libraries)
171+
if attribute_form.exec_():
172+
feature = save_plan(attribute_form.model)
173+
143174
def add_new_plan_feature(self):
144175
if not handle_unsaved_changes():
145176
return
@@ -166,11 +197,10 @@ def _plan_geom_digitized(self, feature: QgsFeature):
166197
if not plan_layer:
167198
return
168199

169-
attribute_form = PlanAttributeForm(self.regulation_group_libraries)
200+
plan_model = Plan(geom=feature.geometry())
201+
attribute_form = PlanAttributeForm(plan_model, self.regulation_group_libraries)
170202
if attribute_form.exec_():
171-
plan_attributes = attribute_form.get_plan_attributes()
172-
plan_attributes.geom = feature.geometry()
173-
feature = save_plan(plan_attributes)
203+
feature = save_plan(attribute_form.model)
174204
plan_to_be_activated = feature["id"]
175205
else:
176206
plan_to_be_activated = self.previous_active_plan_id
@@ -200,6 +230,18 @@ def _plan_feature_geom_digitized(self, feature: QgsFeature):
200230
if attribute_form.exec_():
201231
save_plan_feature(attribute_form.model)
202232

233+
def edit_plan_feature(self, feature: QgsFeature, layer_name: str):
234+
layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP[layer_name]
235+
plan_feature = layer_class.model_from_feature(feature)
236+
237+
# Geom editing handled with basic QGIS vertex editing?
238+
title = plan_feature.name if plan_feature.name else layer_name
239+
attribute_form = PlanFeatureForm(
240+
plan_feature, title, [*self.regulation_group_libraries, regulation_group_library_from_active_plan()]
241+
)
242+
if attribute_form.exec_():
243+
save_plan_feature(attribute_form.model)
244+
203245
def set_active_plan(self, plan_id: str | None):
204246
"""Update the project layers based on the selected land use plan.
205247
@@ -342,59 +384,86 @@ def _save_feature(feature: QgsFeature, layer: QgsVectorLayer, id_: int | None, e
342384
layer.commitChanges(stopEditing=False)
343385

344386

345-
def save_plan(plan_data: Plan) -> QgsFeature:
346-
plan_layer = PlanLayer.get_from_project()
347-
in_edit_mode = plan_layer.isEditable()
348-
if not in_edit_mode:
349-
plan_layer.startEditing()
387+
def _delete_feature(feature: QgsFeature, layer: QgsVectorLayer, delete_text: str = ""):
388+
if not layer.isEditable():
389+
layer.startEditing()
390+
layer.beginEditCommand(delete_text)
350391

351-
edit_message = "Kaavan lisäys" if plan_data.id_ is None else "Kaavan muokkaus"
352-
plan_layer.beginEditCommand(edit_message)
392+
layer.deleteFeature(feature.id())
353393

354-
# plan_data.organisation_id = "99e20d66-9730-4110-815f-5947d3f8abd3"
355-
plan_feature = PlanLayer.feature_from_model(plan_data)
394+
layer.endEditCommand()
395+
layer.commitChanges(stopEditing=False)
356396

357-
if plan_data.id_ is None:
358-
plan_layer.addFeature(plan_feature)
359-
else:
360-
plan_layer.updateFeature(plan_feature)
361397

362-
plan_layer.endEditCommand()
363-
plan_layer.commitChanges(stopEditing=False)
398+
def save_plan(plan: Plan) -> QgsFeature:
399+
feature = PlanLayer.feature_from_model(plan)
400+
layer = PlanLayer.get_from_project()
401+
402+
editing = plan.id_ is not None
403+
_save_feature(
404+
feature=feature,
405+
layer=layer,
406+
id_=plan.id_,
407+
edit_text="Kaavan muokkaus" if editing else "Kaavan luominen",
408+
)
409+
410+
# Check for deleted general regulations
411+
if editing:
412+
for association in RegulationGroupAssociationLayer.get_dangling_associations(
413+
plan.general_regulations, feature["id"], PlanLayer.name
414+
):
415+
_delete_feature(
416+
association,
417+
RegulationGroupAssociationLayer.get_from_project(),
418+
"Kaavamääräysryhmän assosiaation poisto",
419+
)
364420

365-
if plan_data.general_regulations:
366-
for regulation_group in plan_data.general_regulations:
367-
plan_id = plan_feature["id"]
421+
# Save general regulations
422+
if plan.general_regulations:
423+
for regulation_group in plan.general_regulations:
424+
plan_id = feature["id"]
368425
regulation_group_feature = save_regulation_group(regulation_group, plan_id)
369426
save_regulation_group_association(regulation_group_feature["id"], PlanLayer.name, plan_id)
370427

371-
return plan_feature
428+
return feature
372429

373430

374431
def save_plan_feature(plan_feature: PlanFeature, plan_id: str | None = None) -> QgsFeature:
375-
if not plan_feature.layer_name:
432+
layer_name = plan_feature.layer_name
433+
if not layer_name:
376434
msg = "Cannot save plan feature without a target layer"
377435
raise ValueError(msg)
378-
layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP.get(plan_feature.layer_name)
436+
layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP.get(layer_name)
379437
if not layer_class:
380-
msg = f"Could not find plan feature layer class for layer name {plan_feature.layer_name}"
438+
msg = f"Could not find plan feature layer class for layer name {layer_name}"
381439
raise ValueError(msg)
382440

383441
feature = layer_class.feature_from_model(plan_feature, plan_id)
384442
layer = layer_class.get_from_project()
385443

444+
editing = plan_feature.id_ is not None
386445
_save_feature(
387446
feature=feature,
388447
layer=layer,
389448
id_=plan_feature.id_,
390-
edit_text="Kaavakohteen lisäys" if plan_feature.id_ is None else "Kaavakohteen muokkaus",
449+
edit_text="Kaavakohteen muokkaus" if editing else "Kaavakohteen lisäys",
391450
)
392451

393-
# Handle regulation groups
394-
if plan_feature.regulation_groups:
395-
for group in plan_feature.regulation_groups:
396-
regulation_group_feature = save_regulation_group(group)
397-
save_regulation_group_association(regulation_group_feature["id"], plan_feature.layer_name, feature["id"])
452+
# Check for deleted regulation groups
453+
if editing:
454+
for association in RegulationGroupAssociationLayer.get_dangling_associations(
455+
plan_feature.regulation_groups, feature["id"], layer_name
456+
):
457+
_delete_feature(
458+
association,
459+
RegulationGroupAssociationLayer.get_from_project(),
460+
"Kaavamääräysryhmän assosiaation poisto",
461+
)
462+
463+
# Save regulation groups
464+
for group in plan_feature.regulation_groups:
465+
regulation_group_feature = save_regulation_group(group)
466+
save_regulation_group_association(regulation_group_feature["id"], layer_name, feature["id"])
398467

399468
return feature
400469

@@ -423,14 +492,14 @@ def save_regulation_group_as_config(regulation_group: RegulationGroup):
423492
pass
424493

425494

426-
def save_regulation_group_association(regulation_group_id: str, layer_name: str, feature_id: str) -> QgsFeature:
495+
def save_regulation_group_association(regulation_group_id: str, layer_name: str, feature_id: str):
496+
if RegulationGroupAssociationLayer.association_exists(regulation_group_id, layer_name, feature_id):
497+
return
427498
feature = RegulationGroupAssociationLayer.feature_from(regulation_group_id, layer_name, feature_id)
428499
layer = RegulationGroupAssociationLayer.get_from_project()
429500

430501
_save_feature(feature=feature, layer=layer, id_=None, edit_text="Kaavamääräysryhmän assosiaation lisäys")
431502

432-
return feature
433-
434503

435504
def save_regulation(regulation: Regulation) -> QgsFeature:
436505
feature = PlanRegulationLayer.feature_from_model(regulation)

arho_feature_template/exceptions.py

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ def __init__(self, layer_name: str):
2121
super().__init__(f"Layer {layer_name} is not a vector layer")
2222

2323

24+
class LayerNameNotFoundError(Exception):
25+
def __init__(self, layer_name: str):
26+
super().__init__(f"Layer {layer_name} not found")
27+
28+
2429
class ConfigSyntaxError(Exception):
2530
def __init__(self, message: str):
2631
super().__init__(f"Invalid config syntax: {message}")

arho_feature_template/gui/components/code_combobox.py

+50-2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None:
3939
def value(self) -> str:
4040
return self.currentData()
4141

42+
def set_value(self, value: str | None) -> None:
43+
if value is None:
44+
self.setCurrentIndex(0)
45+
return
46+
47+
index = self.findData(value)
48+
if index != -1:
49+
self.setCurrentIndex(index)
50+
else:
51+
self.setCurrentIndex(0) # Set selection to NULL if item with `value` was not found
52+
4253

4354
class HierarchicalCodeComboBox(QComboBox):
4455
def __init__(self, parent=None):
@@ -56,8 +67,8 @@ def __init__(self, parent=None):
5667
null_item = QTreeWidgetItem(["NULL"])
5768
null_item.setData(0, Qt.UserRole, None)
5869
self.tree_widget.addTopLevelItem(null_item)
59-
null_index = self.tree_widget.indexFromItem(null_item)
60-
self.tree_widget.setCurrentIndex(null_index)
70+
self.null_index = self.tree_widget.indexFromItem(null_item)
71+
self.tree_widget.setCurrentIndex(self.null_index)
6172

6273
def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None:
6374
try:
@@ -90,3 +101,40 @@ def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None:
90101
def value(self) -> str:
91102
item = self.tree_widget.selectedItems()[0]
92103
return item.data(0, Qt.UserRole)
104+
105+
def _find_item_recursive(self, item: QTreeWidgetItem, value: str) -> QTreeWidgetItem:
106+
"""Recursively try to find item with given value and return the item if found."""
107+
# Found item, return it
108+
if item.data(0, Qt.UserRole) == value:
109+
return item
110+
111+
# Loop children
112+
for i in range(item.childCount()):
113+
found_item = self._find_item_recursive(item.child(i), value)
114+
if found_item:
115+
return found_item
116+
117+
return None
118+
119+
def set_value(self, value: str | None) -> None:
120+
# Set selection to NULL if `value` is None
121+
if value is None:
122+
self.setCurrentIndex(self.null_index)
123+
return
124+
125+
# Loop top level tree items
126+
for i in range(self.count()):
127+
# Handle child items recursively
128+
found_item = self._find_item_recursive(self.tree_widget.topLevelItem(i), value)
129+
130+
# If matching item was found, set it as selected. Because of the hybrid TreeWidget + ComboBox
131+
# nature of the widget, value setting is unintuitive and tricky
132+
if found_item:
133+
self.tree_widget.setCurrentItem(found_item)
134+
idx = self.tree_widget.indexFromItem(found_item)
135+
self.setRootModelIndex(idx.parent())
136+
self.setCurrentIndex(idx.row())
137+
self.setRootModelIndex(self.null_index.parent())
138+
return
139+
140+
self.setCurrentIndex(self.null_index) # Set selection to NULL if item with `value` was not found

0 commit comments

Comments
 (0)