diff --git a/arho_feature_template/core/models.py b/arho_feature_template/core/models.py
index a341fbd..8b12e67 100644
--- a/arho_feature_template/core/models.py
+++ b/arho_feature_template/core/models.py
@@ -18,6 +18,7 @@
from datetime import datetime
from qgis.core import QgsFeature, QgsGeometry
+ from qgis.PyQt.QtCore import QDate
logger = logging.getLogger(__name__)
@@ -96,6 +97,7 @@ def from_config_file(
for group_name in feature_data.get("regulation_groups", [])
if (group := cls.find_matching_group_config(group_name, regulation_group_libraries))
],
+ lifecycles=[],
plan_id=None,
id_=None,
)
@@ -514,6 +516,23 @@ def from_config_data(cls, data: dict) -> RegulationGroup:
)
+@dataclass
+class LifeCycle:
+ status_id: str | None = None
+ id_: str | None = None
+ plan_id: str | None = None
+ plan_regulation_id: str | None = None
+ plan_proposition_id: str | None = None
+ starting_at: QDate | None = None
+ ending_at: QDate | None = None
+ # might not need the following
+ land_use_are_id: str | None = None
+ other_area_id: str | None = None
+ line_id: str | None = None
+ land_use_point_id: str | None = None
+ other_point_id: str | None = None
+
+
@dataclass
class PlanFeature:
geom: QgsGeometry | None = None # Need to allow None for feature templates
@@ -522,6 +541,7 @@ class PlanFeature:
name: str | None = None
description: str | None = None
regulation_groups: list[RegulationGroup] = field(default_factory=list)
+ lifecycles: list[LifeCycle] = field(default_factory=list)
plan_id: int | None = None
id_: str | None = None
@@ -537,6 +557,7 @@ class Plan:
description: str | None = None
plan_type_id: str | None = None
lifecycle_status_id: str | None = None
+ lifecycles: list[LifeCycle] = field(default_factory=list)
record_number: str | None = None
matter_management_identifier: str | None = None
permanent_plan_identifier: str | None = None
diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py
index 2cd1b94..10f8088 100644
--- a/arho_feature_template/core/plan_manager.py
+++ b/arho_feature_template/core/plan_manager.py
@@ -14,6 +14,7 @@
AdditionalInformation,
Document,
FeatureTemplateLibrary,
+ LifeCycle,
Plan,
PlanFeature,
RegulationGroup,
@@ -21,6 +22,7 @@
RegulationGroupLibrary,
)
from arho_feature_template.exceptions import UnsavedChangesError
+from arho_feature_template.gui.dialogs.lifecycle_editor import LifecycleEditor
from arho_feature_template.gui.dialogs.load_plan_dialog import LoadPlanDialog
from arho_feature_template.gui.dialogs.plan_attribute_form import PlanAttributeForm
from arho_feature_template.gui.dialogs.plan_feature_form import PlanFeatureForm
@@ -35,6 +37,7 @@
DocumentLayer,
LandUseAreaLayer,
LandUsePointLayer,
+ LifeCycleLayer,
LineLayer,
OtherAreaLayer,
OtherPointLayer,
@@ -231,7 +234,6 @@ def edit_plan(self):
feature = PlanLayer.get_feature_by_id(get_active_plan_id(), no_geometries=False)
if feature is None:
- iface.messageBar().pushWarning("", "No active/open plan found!")
return
plan_model = PlanLayer.model_from_feature(feature)
@@ -240,6 +242,21 @@ def edit_plan(self):
feature = save_plan(attribute_form.model)
self.regulation_groups_dock.initialize_regulation_groups(regulation_group_library_from_active_plan())
+ def edit_lifecycles(self):
+ plan_layer = PlanLayer.get_from_project()
+ if not plan_layer:
+ return
+
+ feature = PlanLayer.get_feature_by_id(get_active_plan_id(), no_geometries=False)
+ if feature is None:
+ return
+ plan_model = PlanLayer.model_from_feature(feature)
+
+ lifecycle_editor = LifecycleEditor(plan=plan_model)
+
+ if lifecycle_editor.exec_():
+ save_plan(plan_model)
+
def add_new_plan_feature(self):
if not handle_unsaved_changes():
return
@@ -304,7 +321,6 @@ def edit_plan_feature(self, feature: QgsFeature, layer_name: str):
layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP[layer_name]
plan_feature = layer_class.model_from_feature(feature)
- # Geom editing handled with basic QGIS vertex editing?
title = plan_feature.name if plan_feature.name else layer_name
attribute_form = PlanFeatureForm(
plan_feature, title, [*self.regulation_group_libraries, regulation_group_library_from_active_plan()]
@@ -534,6 +550,11 @@ def save_plan(plan: Plan) -> QgsFeature:
document.plan_id = plan_id
save_document(document)
+ # Save lifecycles
+ for lifecycle in plan.lifecycles:
+ lifecycle.plan_id = plan_id
+ save_lifecycle(lifecycle)
+
return feature
@@ -736,3 +757,17 @@ def save_document(document: Document) -> QgsFeature:
)
return feature
+
+
+def save_lifecycle(lifecycle: LifeCycle) -> QgsFeature:
+ """Save a LifeCycle object to the layer."""
+ feature = LifeCycleLayer.feature_from_model(lifecycle)
+ layer = LifeCycleLayer.get_from_project()
+
+ _save_feature(
+ feature=feature,
+ layer=layer,
+ id_=lifecycle.id_,
+ edit_text="Elinkaaren lisäys" if lifecycle.id_ is None else "Elinkaaren muokkaus",
+ )
+ return feature
diff --git a/arho_feature_template/gui/components/lifecycle_table_widget.py b/arho_feature_template/gui/components/lifecycle_table_widget.py
new file mode 100644
index 0000000..af87838
--- /dev/null
+++ b/arho_feature_template/gui/components/lifecycle_table_widget.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from qgis.gui import QgsDateTimeEdit
+from qgis.PyQt.QtCore import Qt, pyqtSignal
+from qgis.PyQt.QtWidgets import (
+ QHeaderView,
+ QTableWidget,
+ QTableWidgetItem,
+)
+
+from arho_feature_template.core.models import LifeCycle
+from arho_feature_template.gui.components.code_combobox import CodeComboBox
+from arho_feature_template.project.layers.code_layers import LifeCycleStatusLayer
+
+
+class LifecycleTableWidget(QTableWidget):
+ table_edited = pyqtSignal()
+
+ def __init__(self, lifecycles: list[LifeCycle], parent=None):
+ super().__init__(parent)
+
+ self.lifecycles = lifecycles
+
+ # Initialize table widget
+ self.setColumnCount(3)
+ self.setHorizontalHeaderLabels(["Elinkaaren tila", "Alkupäivämäärä", "Loppupäivämäärä"])
+
+ header = self.horizontalHeader()
+ header.setSectionResizeMode(0, QHeaderView.Stretch)
+ self.setMinimumWidth(600)
+ self.setColumnWidth(1, 120)
+ self.setColumnWidth(2, 120)
+
+ # Add existing lifecycles
+ for lifecycle in sorted(lifecycles, key=lambda x: x.starting_at): # type: ignore[arg-type, return-value]
+ self.add_lifecycle_row(lifecycle)
+
+ # If 0 rows, initialize 1 row since Plan needs at least one lifecycle
+ if self.rowCount() == 0:
+ self.add_new_lifecycle_row()
+
+ def add_new_lifecycle_row(self):
+ self.add_lifecycle_row(LifeCycle())
+
+ def add_lifecycle_row(self, lifecycle: LifeCycle):
+ row_position = self.rowCount()
+ self.insertRow(row_position)
+
+ id_item = QTableWidgetItem()
+ id_item.setData(Qt.UserRole, lifecycle.id_)
+ self.setItem(row_position, 0, id_item)
+
+ status = CodeComboBox()
+ status.populate_from_code_layer(LifeCycleStatusLayer)
+ status.currentIndexChanged.connect(self.table_edited.emit)
+ if lifecycle.status_id:
+ status.set_value(lifecycle.status_id)
+ self.setCellWidget(row_position, 0, status)
+
+ start_date_edit = QgsDateTimeEdit()
+ start_date_edit.setDisplayFormat("yyyy-MM-dd")
+ start_date_edit.setCalendarPopup(True)
+ start_date_edit.valueChanged.connect(self.table_edited.emit)
+ if lifecycle.starting_at:
+ start_date_edit.setDate(lifecycle.starting_at.date())
+ self.setCellWidget(row_position, 1, start_date_edit)
+
+ end_date_edit = QgsDateTimeEdit()
+ end_date_edit.setDisplayFormat("yyyy-MM-dd")
+ end_date_edit.setCalendarPopup(True)
+ end_date_edit.setSpecialValueText("") # Allow empty value
+ if lifecycle.ending_at:
+ end_date_edit.setDate(lifecycle.ending_at.date())
+ else:
+ end_date_edit.clear()
+ self.setCellWidget(row_position, 2, end_date_edit)
+
+ self.table_edited.emit()
+
+ def is_ok(self) -> bool:
+ for row in range(self.rowCount()):
+ status_item = self.cellWidget(row, 0)
+ start_date_item = self.cellWidget(row, 1)
+ if status_item.value() is None or start_date_item.date().isNull():
+ return False
+
+ return True
+
+ def row_into_model(self, row_i: int) -> LifeCycle:
+ status_item = self.cellWidget(row_i, 0)
+ start_date_item = self.cellWidget(row_i, 1)
+ end_date_item = self.cellWidget(row_i, 2)
+
+ id_item = self.item(row_i, 0)
+
+ return LifeCycle(
+ status_id=status_item.value(),
+ starting_at=start_date_item.date(),
+ ending_at=end_date_item.date(),
+ id_=id_item.data(Qt.UserRole),
+ )
+
+ def into_model(self) -> list[LifeCycle]:
+ """Extracts all lifecycle data from the table into a list of LifeCycle objects."""
+ return [self.row_into_model(row) for row in range(self.rowCount())]
diff --git a/arho_feature_template/gui/dialogs/lifecycle_editor.py b/arho_feature_template/gui/dialogs/lifecycle_editor.py
new file mode 100644
index 0000000..515ce00
--- /dev/null
+++ b/arho_feature_template/gui/dialogs/lifecycle_editor.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from importlib import resources
+from typing import TYPE_CHECKING
+
+from qgis.core import QgsApplication
+from qgis.PyQt import uic
+from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QPushButton
+
+from arho_feature_template.gui.components.lifecycle_table_widget import LifecycleTableWidget
+
+if TYPE_CHECKING:
+ from arho_feature_template.core.models import Plan
+
+ui_path = resources.files(__package__) / "lifecycle_editor.ui"
+FormClass, _ = uic.loadUiType(ui_path)
+
+
+class LifecycleEditor(QDialog, FormClass): # type: ignore
+ add_lifecycle_button: QPushButton
+ button_box: QDialogButtonBox
+
+ def __init__(self, plan: Plan, parent=None):
+ super().__init__(parent)
+ self.setupUi(self)
+
+ self.plan = plan
+ if not self.plan:
+ self.reject()
+ return
+
+ self.lifecycle_table = LifecycleTableWidget(self.plan.lifecycles)
+ self.layout().insertWidget(1, self.lifecycle_table)
+ self.add_lifecycle_button.setIcon(QgsApplication.getThemeIcon("mActionAdd.svg"))
+
+ self.add_lifecycle_button.clicked.connect(self.lifecycle_table.add_new_lifecycle_row)
+ self.lifecycle_table.table_edited.connect(self._check_required_fields)
+
+ self.button_box.accepted.connect(self._on_ok_clicked)
+ self.button_box.rejected.connect(self.reject)
+ self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
+
+ self._check_required_fields()
+
+ def _check_required_fields(self) -> None:
+ """Enable/disable the OK button based on the validation of the lifecycle table."""
+ ok_button = self.button_box.button(QDialogButtonBox.Ok)
+ ok_button.setEnabled(self.lifecycle_table.is_ok())
+
+ def _on_ok_clicked(self):
+ """Replace existing lifecycles with updated data and save the plan."""
+ if not self.plan:
+ return
+
+ self.plan.lifecycles = self.lifecycle_table.into_model()
+
+ self.accept()
+
+ def reject(self):
+ super().reject()
diff --git a/arho_feature_template/gui/dialogs/lifecycle_editor.ui b/arho_feature_template/gui/dialogs/lifecycle_editor.ui
new file mode 100644
index 0000000..3cb2c9c
--- /dev/null
+++ b/arho_feature_template/gui/dialogs/lifecycle_editor.ui
@@ -0,0 +1,77 @@
+
+
+ Dialog
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ Kaavan elinkaaret
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Lisää elinkaari
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+ button_box
+ accepted()
+ Dialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ button_box
+ rejected()
+ Dialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/arho_feature_template/gui/dialogs/plan_attribute_form.py b/arho_feature_template/gui/dialogs/plan_attribute_form.py
index 9a5ad62..e7bd8a8 100644
--- a/arho_feature_template/gui/dialogs/plan_attribute_form.py
+++ b/arho_feature_template/gui/dialogs/plan_attribute_form.py
@@ -9,13 +9,14 @@
QDialog,
QDialogButtonBox,
QLineEdit,
+ QPushButton,
QTextEdit,
)
from arho_feature_template.core.models import Document, Plan, RegulationGroup, RegulationGroupLibrary
+from arho_feature_template.gui.components.general_regulation_group_widget import GeneralRegulationGroupWidget
# from arho_feature_template.gui.components.plan_regulation_group_widget import RegulationGroupWidget
-from arho_feature_template.gui.components.general_regulation_group_widget import GeneralRegulationGroupWidget
from arho_feature_template.gui.components.plan_document_widget import DocumentWidget
from arho_feature_template.project.layers.code_layers import (
LifeCycleStatusLayer,
@@ -61,6 +62,7 @@ def __init__(self, plan: Plan, _regulation_group_libraries: list[RegulationGroup
self.setupUi(self)
self.plan = plan
+ self.lifecycle_models = plan.lifecycles
self.plan_type_combo_box.populate_from_code_layer(PlanTypeLayer)
self.lifecycle_status_combo_box.populate_from_code_layer(LifeCycleStatusLayer)
@@ -98,6 +100,7 @@ def __init__(self, plan: Plan, _regulation_group_libraries: list[RegulationGroup
for regulation_group in plan.general_regulations:
self.add_plan_regulation_group(regulation_group)
+ # Documents
self.document_widgets: list[DocumentWidget] = []
for document in plan.documents:
self.add_document(document)
diff --git a/arho_feature_template/gui/dialogs/plan_attribute_form.ui b/arho_feature_template/gui/dialogs/plan_attribute_form.ui
index 28218ab..93c6dd0 100644
--- a/arho_feature_template/gui/dialogs/plan_attribute_form.ui
+++ b/arho_feature_template/gui/dialogs/plan_attribute_form.ui
@@ -16,7 +16,7 @@
Kaava
-
+
-
@@ -26,7 +26,7 @@
- 0
+ 3
@@ -148,7 +148,7 @@
-
- <span style="color: red;">*</span> Elinkaaren tila:
+ Elinkaaren tila*:
lifecycle_status_combo_box
@@ -165,7 +165,7 @@
-
- <span style="color: red;">*</span> Organisaatio:
+ Organisaatio*:
diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py
index 760deff..5522a68 100644
--- a/arho_feature_template/plugin.py
+++ b/arho_feature_template/plugin.py
@@ -227,6 +227,16 @@ def initGui(self) -> None: # noqa N802
status_tip="Tallenna aktiivinen kaava JSON muodossa",
)
+ self.edit_lifecycles_action = self.add_action(
+ text="Kaavan elinkaaret",
+ icon=QgsApplication.getThemeIcon("mIconFieldDate.svg"),
+ # icon=QIcon(resources_path("icons", "toolbar", "tallenna_jsonina2.svg")),
+ triggered_callback=self.edit_lifecycles,
+ add_to_menu=True,
+ add_to_toolbar=True,
+ status_tip="Muokkaa kaavan elinkaaria",
+ )
+
self.plugin_settings_action = self.add_action(
text="Asetukset",
triggered_callback=self.open_settings,
@@ -254,6 +264,10 @@ def open_settings(self):
settings = PluginSettings()
settings.exec_()
+ def edit_lifecycles(self):
+ """Edit lifecycles of currently active plan."""
+ self.plan_manager.edit_lifecycles()
+
def unload(self) -> None:
"""Removes the plugin menu item and icon from QGIS GUI."""
# Handle signals
diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py
index 06a5228..7a94ea2 100644
--- a/arho_feature_template/project/layers/plan_layers.py
+++ b/arho_feature_template/project/layers/plan_layers.py
@@ -13,6 +13,7 @@
AdditionalInformationConfigLibrary,
AttributeValue,
Document,
+ LifeCycle,
Plan,
PlanFeature,
Proposition,
@@ -34,11 +35,13 @@
class AbstractPlanLayer(AbstractLayer):
- filter_template: ClassVar[Template]
+ filter_template: ClassVar[Template | None]
@classmethod
def apply_filter(cls, plan_id: str | None) -> None:
"""Apply a filter to the layer based on the plan_id."""
+ if cls.filter_template is None:
+ return
filter_expression = cls.filter_template.substitute(plan_id=plan_id) if plan_id else ""
layer = cls.get_from_project()
if layer.isEditable():
@@ -122,6 +125,11 @@ def model_from_feature(cls, feature: QgsFeature) -> Plan:
for feat in DocumentLayer.get_features_by_attribute_value("plan_id", feature["id"])
],
id_=feature["id"],
+ lifecycles=[
+ LifeCycleLayer.model_from_feature(feat)
+ for feat in LifeCycleLayer.get_features_by_plan_id(feature["id"])
+ if feat is not None
+ ],
)
@classmethod
@@ -154,6 +162,8 @@ def model_from_feature(cls, feature: QgsFeature) -> PlanFeature:
RegulationGroupLayer.get_feature_by_id(group_id)
for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name)
]
+ plan_lifecycle_features = [LifeCycleLayer.get_features_by_plan_id(feature["id"])]
+
return PlanFeature(
geom=feature.geometry(),
type_of_underground_id=feature["type_of_underground_id"],
@@ -163,6 +173,9 @@ def model_from_feature(cls, feature: QgsFeature) -> PlanFeature:
regulation_groups=[
RegulationGroupLayer.model_from_feature(feat) for feat in regulation_group_features if feat is not None
],
+ lifecycles=[
+ LifeCycleLayer.model_from_feature(feat) for feat in plan_lifecycle_features if feat is not None
+ ],
plan_id=feature["plan_id"],
id_=feature["id"],
)
@@ -568,6 +581,51 @@ def model_from_feature(cls, feature: QgsFeature) -> AdditionalInformation:
)
+class LifeCycleLayer(AbstractPlanLayer):
+ name = "Elinkaaren päiväykset"
+ filter_template = None
+
+ @classmethod
+ def feature_from_model(cls, model: LifeCycle) -> QgsFeature:
+ feature = cls.initialize_feature_from_model(model)
+
+ feature["id"] = model.id_ if model.id_ else feature["id"]
+ feature["lifecycle_status_id"] = model.status_id
+ feature["starting_at"] = model.starting_at
+ feature["ending_at"] = model.ending_at if model.ending_at else None
+ feature["plan_id"] = model.plan_id
+ feature["land_use_area_id"] = model.land_use_are_id
+ feature["other_area_id"] = model.other_area_id
+ feature["line_id"] = model.line_id
+ feature["land_use_point_id"] = model.land_use_point_id
+ feature["other_point_id"] = model.other_point_id
+ feature["plan_regulation_id"] = model.plan_regulation_id
+ feature["plan_proposition_id"] = model.plan_proposition_id
+
+ return feature
+
+ @classmethod
+ def model_from_feature(cls, feature: QgsFeature) -> LifeCycle:
+ return LifeCycle(
+ id_=feature["id"],
+ status_id=feature["lifecycle_status_id"],
+ starting_at=feature["starting_at"],
+ ending_at=feature["ending_at"],
+ plan_id=feature["plan_id"],
+ land_use_are_id=feature["land_use_area_id"],
+ other_area_id=feature["other_area_id"],
+ line_id=feature["line_id"],
+ land_use_point_id=feature["land_use_point_id"],
+ other_point_id=feature["other_point_id"],
+ plan_regulation_id=feature["plan_regulation_id"],
+ plan_proposition_id=feature["plan_proposition_id"],
+ )
+
+ @classmethod
+ def get_features_by_plan_id(cls, plan_id: str) -> list[QgsFeature]:
+ return list(cls.get_features_by_attribute_value("plan_id", plan_id))
+
+
plan_layers = AbstractPlanLayer.__subclasses__()
plan_layers.remove(PlanFeatureLayer)