From 9370c03e90c1788749d0ec8ae5d2a9239c84cda1 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Mon, 30 Mar 2026 20:30:32 +0200 Subject: [PATCH] =?UTF-8?q?Feat:=20Veraltete=20XSL-Eintr=C3=A4ge=20nach=20?= =?UTF-8?q?DB-Import=20erkennen=20und=20entfernen=20(v1.2.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Import aus der PostgreSQL-Datenbank werden nun XSL-Einträge erkannt, die nicht mehr in der DB vorhanden sind. Ein Dialog zeigt diese gruppiert in einer Baumansicht an und bietet die Option, sie samt nicht mehr verwendeter XML-/PDF-Dateien aus dem Projekt zu entfernen. Leere TreeNodes werden automatisch bereinigt. Zusätzlich: SQL-Filter `r3.export = 0` in data.sql ergänzt. Co-Authored-By: Claude Opus 4.6 --- DocuMentor.wxs | 2 +- THIRD_PARTY_LICENSES.txt | 2 +- installer.iss | 2 +- pyproject.toml | 2 +- src/obsolete_detector.py | 166 ++++++++++++++++++++++++++++++++ src/res/data.sql | 2 +- src/ui/ObsoleteEntriesDialog.py | 137 ++++++++++++++++++++++++++ src/ui/mixins/database.py | 89 +++++++++++++++++ uv.lock | 2 +- 9 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 src/obsolete_detector.py create mode 100644 src/ui/ObsoleteEntriesDialog.py diff --git a/DocuMentor.wxs b/DocuMentor.wxs index 690de9d..beeaa71 100644 --- a/DocuMentor.wxs +++ b/DocuMentor.wxs @@ -4,7 +4,7 @@ set[tuple]: + """ + Extrahiert alle XslFile-IDs aus den frisch aus der DB geladenen Nodes. + + Args: + new_nodes: Nodes aus _process_sql_data() + + Returns: + Set aller XslFile-IDs (Tupel) + """ + ids: set[tuple] = set() + _collect_ids_recursive(new_nodes, ids) + return ids + + +def _collect_ids_recursive(nodes: list, ids: set[tuple]) -> None: + for node in nodes: + if isinstance(node, XslFile): + ids.add(node.id) + elif isinstance(node, TreeNode) and node.children: + _collect_ids_recursive(node.children, ids) + + +def find_obsolete_xsl_entries( + project_nodes: list[TreeNode], + db_xsl_ids: set[tuple], +) -> list[ObsoleteGroup]: + """ + Findet alle XslFile-Einträge im Projekt, die nicht mehr in der DB vorhanden sind. + + Args: + project_nodes: Aktuelle Nodes aus pdf_project.nodes + db_xsl_ids: Set aller XslFile-IDs aus der DB (von extract_db_xsl_ids) + + Returns: + Liste von ObsoleteGroup, sortiert nach Hierarchiepfad + """ + groups: dict[int, ObsoleteGroup] = {} + + _find_obsolete_recursive(project_nodes, db_xsl_ids, path=[], groups=groups) + + # Sortieren nach Pfad für stabile Darstellung + return sorted(groups.values(), key=lambda g: g.node_path) + + +def _find_obsolete_recursive( + nodes: list, + db_xsl_ids: set[tuple], + path: list[str], + groups: dict[int, ObsoleteGroup], +) -> None: + for node in nodes: + if isinstance(node, TreeNode): + _find_obsolete_in_tree_node(node, db_xsl_ids, path, groups) + + +def _find_obsolete_in_tree_node( + node: TreeNode, + db_xsl_ids: set[tuple], + parent_path: list[str], + groups: dict[int, ObsoleteGroup], +) -> None: + current_path = parent_path + [node.bez] + + for child in node.children: + if isinstance(child, XslFile): + if child.id not in db_xsl_ids: + node_id = id(node) + if node_id not in groups: + groups[node_id] = ObsoleteGroup( + node_path=current_path, + parent_node=node, + ) + groups[node_id].xsl_entries.append(ObsoleteXslEntry(xsl_file=child, parent_node=node)) + elif isinstance(child, TreeNode): + _find_obsolete_in_tree_node(child, db_xsl_ids, current_path, groups) + + +def remove_empty_tree_nodes(nodes: list) -> list: + """ + Entfernt rekursiv alle TreeNodes, deren children-Liste nach der Bereinigung leer ist. + XslFile-Einträge werden immer behalten (sie wurden bereits entfernt oder sind noch gültig). + + Args: + nodes: Liste von TreeNode|XslFile + + Returns: + Bereinigte Liste ohne leere TreeNodes + """ + result = [] + for node in nodes: + if isinstance(node, TreeNode): + node.children = remove_empty_tree_nodes(node.children) + if node.children: + result.append(node) + # leere TreeNodes stillschweigend verwerfen + else: + result.append(node) + return result + + +def collect_unused_xml_files( + obsolete_groups: list[ObsoleteGroup], + project_dir: Path, + is_xml_used_elsewhere_fn, +) -> list[tuple[Path, Path]]: + """ + Sammelt XML-Dateien der veralteten XslFiles, die nirgends anders mehr verwendet werden. + + WICHTIG: Muss nach dem Entfernen der XslFiles aus dem Modell aufgerufen werden, + damit is_xml_used_elsewhere_fn korrekte Ergebnisse liefert. + + Args: + obsolete_groups: Die veralteten Gruppen + project_dir: Absoluter Pfad zum Projektverzeichnis + is_xml_used_elsewhere_fn: Callable(xml_path, exclude_xsl_file) -> bool + + Returns: + Liste von (xml_path_relativ, xml_path_absolut) für nicht mehr verwendete XML-Dateien + """ + unused: list[tuple[Path, Path]] = [] + seen: set[str] = set() + + for group in obsolete_groups: + for entry in group.xsl_entries: + for xml_file_obj in entry.xsl_file.xmls: + xml_path_str = str(xml_file_obj.xml) + if xml_path_str in seen: + continue + seen.add(xml_path_str) + xml_abs = project_dir / xml_file_obj.xml + if xml_abs.exists(): + if not is_xml_used_elsewhere_fn(xml_file_obj.xml, entry.xsl_file): + unused.append((xml_file_obj.xml, xml_abs)) + + return unused diff --git a/src/res/data.sql b/src/res/data.sql index a27c8ea..5b7cef4 100644 --- a/src/res/data.sql +++ b/src/res/data.sql @@ -8,4 +8,4 @@ select r3.xsl_datei from reporttyp r inner join report r2 on r.reporttyp = r2.reporttyp and r2.aktiv = 1 -inner join repfile r3 on r2.reporttyp = r3.reporttyp and r2.report = r3.report and r3.xsl_datei is not null and r3.aktiv = 1 \ No newline at end of file +inner join repfile r3 on r2.reporttyp = r3.reporttyp and r2.report = r3.report and r3.xsl_datei is not null and r3.aktiv = 1 and r3.export = 0 \ No newline at end of file diff --git a/src/ui/ObsoleteEntriesDialog.py b/src/ui/ObsoleteEntriesDialog.py new file mode 100644 index 0000000..a4eb328 --- /dev/null +++ b/src/ui/ObsoleteEntriesDialog.py @@ -0,0 +1,137 @@ +""" +ObsoleteEntriesDialog — Dialog zur Bestätigung des Entfernens veralteter Projekteinträge. + +Zeigt XslFile-Einträge an, die nicht mehr in der Datenbank vorhanden sind, +und lässt den Benutzer entscheiden ob sie entfernt werden sollen. +""" + +import logging +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QLabel, + QSizePolicy, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, +) + +from obsolete_detector import ObsoleteGroup + +logger = logging.getLogger(__name__) + + +class ObsoleteEntriesDialog(QDialog): + """ + Dialog zur Anzeige und Bestätigung veralteter Einträge nach einem DB-Import. + + Zeigt die veralteten XslFile-Einträge gruppiert nach ihrer Baumhierarchie an. + Der Benutzer kann entscheiden ob die Einträge entfernt und ob nicht mehr + verwendete XML-Dateien physisch gelöscht werden sollen. + """ + + def __init__(self, parent, obsolete_groups: list[ObsoleteGroup]): + """ + Args: + parent: Eltern-Widget + obsolete_groups: Veraltete Einträge gruppiert nach Hierarchiepfad + """ + super().__init__(parent) + self._obsolete_groups = obsolete_groups + self._setup_ui() + self._populate_tree() + + def _setup_ui(self) -> None: + """Erstellt die UI-Elemente des Dialogs.""" + total_count = sum(len(g.xsl_entries) for g in self._obsolete_groups) + + self.setWindowTitle("Veraltete Einträge gefunden") + self.resize(640, 420) + self.setSizeGripEnabled(True) + + layout = QVBoxLayout(self) + layout.setSpacing(10) + + # Erklärungstext + info_label = QLabel( + f"{total_count} XSL-Datei(en) sind nicht mehr in der Datenbank vorhanden " + f"und können aus dem Projekt entfernt werden." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + # Baumansicht der veralteten Einträge + self._tree = QTreeWidget() + self._tree.setColumnCount(3) + self._tree.setHeaderLabels(["Bezeichnung", "XSL-Datei", "XML-Dateien"]) + self._tree.setColumnWidth(0, 280) + self._tree.setColumnWidth(1, 200) + self._tree.setColumnWidth(2, 80) + self._tree.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._tree.setAlternatingRowColors(True) + self._tree.setEditTriggers(QTreeWidget.EditTrigger.NoEditTriggers) + layout.addWidget(self._tree) + + # Checkbox für physische XML-Löschung + self._delete_xml_checkbox = QCheckBox("Nicht mehr verwendete XML-Dateien physisch löschen") + self._delete_xml_checkbox.setChecked(False) + layout.addWidget(self._delete_xml_checkbox) + + # Dialog-Buttons + self._button_box = QDialogButtonBox() + remove_button = self._button_box.addButton( + "Veraltete Einträge entfernen", QDialogButtonBox.ButtonRole.AcceptRole + ) + remove_button.setToolTip("Entfernt alle aufgelisteten Einträge aus dem Projekt") + self._button_box.addButton(QDialogButtonBox.StandardButton.Cancel) + self._button_box.accepted.connect(self.accept) + self._button_box.rejected.connect(self.reject) + layout.addWidget(self._button_box) + + def _populate_tree(self) -> None: + """Befüllt den QTreeWidget mit den veralteten Einträgen.""" + self._tree.clear() + + for group in self._obsolete_groups: + # Hierarchiepfad als verschachtelte Items aufbauen + parent_item = self._tree.invisibleRootItem() + for path_part in group.node_path: + # Prüfe ob dieser Pfadteil bereits als Kind vorhanden ist + existing = None + for i in range(parent_item.childCount()): + child = parent_item.child(i) + if child.text(0) == path_part and not child.data(0, Qt.ItemDataRole.UserRole): + existing = child + break + if existing: + parent_item = existing + else: + node_item = QTreeWidgetItem(parent_item, [path_part]) + font = node_item.font(0) + font.setBold(True) + node_item.setFont(0, font) + parent_item = node_item + + # XslFile-Einträge unter dem Hierarchiepfad + for entry in group.xsl_entries: + xsl = entry.xsl_file + xml_count = str(len(xsl.xmls)) if xsl.xmls else "0" + xsl_item = QTreeWidgetItem( + parent_item, + [xsl.bez, xsl.xsl_file.name, xml_count], + ) + xsl_item.setData(0, Qt.ItemDataRole.UserRole, xsl) + xsl_item.setToolTip(1, str(xsl.xsl_file)) + + self._tree.expandAll() + + def delete_xml_files(self) -> bool: + """ + Gibt zurück ob der Benutzer die physische Löschung der XML-Dateien gewünscht hat. + + Returns: + True wenn die Checkbox aktiviert ist + """ + return self._delete_xml_checkbox.isChecked() diff --git a/src/ui/mixins/database.py b/src/ui/mixins/database.py index 7d4e620..8270132 100644 --- a/src/ui/mixins/database.py +++ b/src/ui/mixins/database.py @@ -14,6 +14,13 @@ from PySide6.QtCore import QThread, Signal, Qt from PySide6.QtWidgets import QMessageBox, QProgressDialog from conf import app_settings, TreeNode, XslFile +from obsolete_detector import ( + collect_unused_xml_files, + extract_db_xsl_ids, + find_obsolete_xsl_entries, + remove_empty_tree_nodes, +) +from ui.ObsoleteEntriesDialog import ObsoleteEntriesDialog logger = logging.getLogger(__name__) @@ -31,6 +38,7 @@ class DatabaseQueryThread(QThread): def run(self): import polars as pl + try: df = pl.read_database_uri(self.sql_query, self.connection_string, engine="connectorx").sort( ["reporttyp_bez", "report_bez", "repfile_bez"] @@ -107,6 +115,7 @@ class DatabaseMixin: try: new_nodes = self._process_sql_data(df) self._merge_nodes_with_existing(new_nodes) + self._check_and_remove_obsolete_entries(new_nodes) self._save_project_settings() self._load_nodes_to_tree() except Exception as e: @@ -209,6 +218,7 @@ class DatabaseMixin: list[TreeNode]: Liste der erstellten Root-Nodes """ import polars as pl + try: start_time = time.time() @@ -350,3 +360,82 @@ class DatabaseMixin: if new_child.id not in existing_child_ids: existing_node.children.append(new_child) logger.info(f"Neues Kind hinzugefügt zu Node {existing_node.id}: {new_child.bez}") + + def _check_and_remove_obsolete_entries(self, new_nodes: list[TreeNode]) -> None: + """ + Prüft nach dem Merge ob XslFile-Einträge nicht mehr in der DB vorhanden sind. + Zeigt einen Bestätigungsdialog und entfernt veraltete Einträge inklusive + PDF- und optionaler XML-Bereinigung. + + Args: + new_nodes: Frisch aus der DB geladene Nodes (Ergebnis von _process_sql_data) + """ + if not self.pdf_project or not self.pdf_project.nodes: + return + + try: + db_xsl_ids = extract_db_xsl_ids(new_nodes) + obsolete_groups = find_obsolete_xsl_entries(self.pdf_project.nodes, db_xsl_ids) + + if not obsolete_groups: + logger.debug("Keine veralteten Einträge gefunden") + return + + total_count = sum(len(g.xsl_entries) for g in obsolete_groups) + logger.info(f"{total_count} veraltete XSL-Einträge gefunden") + + dialog = ObsoleteEntriesDialog(self, obsolete_groups) + if dialog.exec() != ObsoleteEntriesDialog.DialogCode.Accepted: + logger.info("Entfernung veralteter Einträge vom Benutzer abgebrochen") + return + + delete_xml = dialog.delete_xml_files() + + # Phase 1: XslFiles aus dem Datenmodell entfernen + # WICHTIG: Zuerst entfernen, damit _is_xml_xsl_combination_used_elsewhere + # und _is_xml_file_used_elsewhere die gelöschten Einträge nicht mehr sehen + # (gleiche Reihenfolge wie in _delete_tree_node) + for group in obsolete_groups: + parent = group.parent_node + obsolete_xsl_ids = {id(entry.xsl_file) for entry in group.xsl_entries} + parent.children = [c for c in parent.children if id(c) not in obsolete_xsl_ids] + logger.info(f"{len(group.xsl_entries)} XSL-Einträge aus '{' > '.join(group.node_path)}' entfernt") + + # Phase 2: PDF-Bereinigung für alle entfernten XslFiles + total_deleted_pdfs = 0 + for group in obsolete_groups: + for entry in group.xsl_entries: + xsl_id = entry.xsl_file.id + for xml_file_obj in entry.xsl_file.xmls: + is_used = self._is_xml_xsl_combination_used_elsewhere(xml_file_obj.xml, xsl_id, entry.xsl_file) + if not is_used: + total_deleted_pdfs += self._delete_pdf_files_for_xml_xsl_combination( + xml_file_obj.xml, xsl_id + ) + + if total_deleted_pdfs > 0: + logger.info(f"{total_deleted_pdfs} PDF-Datei(en) bereinigt") + + # Phase 3: Leere TreeNodes bereinigen (bottom-up durch rekursiven Aufruf) + self.pdf_project.nodes = remove_empty_tree_nodes(self.pdf_project.nodes) + + # Phase 4: Optionale physische XML-Löschung + if delete_xml: + unused_xml = collect_unused_xml_files( + obsolete_groups, + Path(self.project.project_dir), + self._is_xml_file_used_elsewhere, + ) + for _rel, xml_abs in unused_xml: + try: + xml_abs.unlink() + logger.info(f"Physische XML-Datei gelöscht: {xml_abs}") + except Exception as e: + logger.warning(f"Konnte XML-Datei nicht löschen: {xml_abs} - {e}") + + logger.info(f"Bereinigung abgeschlossen: {total_count} veraltete Einträge entfernt") + + except Exception as e: + error_msg = f"Fehler beim Entfernen veralteter Einträge: {str(e)}" + logger.exception(error_msg) + QMessageBox.critical(self, "Fehler", error_msg) diff --git a/uv.lock b/uv.lock index cc1c483..db58aeb 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "documentor" -version = "1.2.5" +version = "1.2.7" source = { virtual = "." } dependencies = [ { name = "connectorx" },