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)