ui.pipeline_review_page

  1import json
  2import os
  3import glob
  4
  5from PyQt6.QtGui import QFont
  6from PyQt6.QtWidgets import QWidget, QScrollArea, QVBoxLayout, QLabel
  7from PyQt6.QtCore import Qt, QCoreApplication
  8
  9from components.collapsible_info_frame import CollapsibleInfoFrame
 10from components.collapsible_patient_frame import CollapsiblePatientFrame
 11from ui.pipeline_execution_page import PipelineExecutionPage
 12from page import Page
 13from logger import get_logger
 14
 15log = get_logger()
 16
 17
 18class PipelineReviewPage(Page):
 19    """
 20    GUI page that allows users to review and adjust the configuration
 21    before running the medical imaging pipeline.
 22
 23    This page:
 24    - Loads the latest configuration JSON file.
 25    - Displays collapsible panels for each patient and data type.
 26    - Allows editing, saving, and validating configurations.
 27    - Transitions to the execution page once all data is ready.
 28    """
 29
 30    def __init__(self, context=None, previous_page=None):
 31        """
 32        Initialize the review page and build its UI.
 33
 34        Args:
 35            context (dict): Shared application context (workspace path, logger, signals, etc.).
 36            previous_page (Page): The previous page in the workflow.
 37        """
 38        super().__init__()
 39        self.context = context
 40        self.workspace_path = context["workspace_path"]
 41        self.previous_page = previous_page
 42        self.next_page = None
 43
 44        # Locate and load the latest configuration file
 45        self.config_path = self._find_latest_config()
 46        self.pipeline_config = self._load_config()
 47
 48        # Main layout container
 49        self.main_layout = QVBoxLayout(self)
 50
 51        # Build interface
 52        self._setup_ui()
 53
 54        # Apply translation and connect to language change signal (if available)
 55        self._translate_ui()
 56        if context and "language_changed" in context:
 57            context["language_changed"].connect(self._translate_ui)
 58
 59    # ─────────────────────────────────────────────
 60    # CONFIGURATION MANAGEMENT
 61    # ─────────────────────────────────────────────
 62    def _find_latest_config(self):
 63        """
 64        Find the most recent pipeline configuration file in the workspace.
 65
 66        Returns:
 67            str: Absolute path to the newest configuration JSON file.
 68        """
 69        pipeline_dir = os.path.join(self.workspace_path, "pipeline")
 70
 71        # Return a default path if the directory does not exist
 72        if not os.path.exists(pipeline_dir):
 73            return os.path.join(pipeline_dir, "pipeline_config.json")
 74
 75        # Locate all files matching the *_config.json pattern
 76        config_files = glob.glob(os.path.join(pipeline_dir, "*_config.json"))
 77
 78        if not config_files:
 79            return os.path.join(pipeline_dir, "pipeline_config.json")
 80
 81        # Identify the latest config file by its numeric ID prefix
 82        max_id = 0
 83        latest_config = None
 84        for config_file in config_files:
 85            filename = os.path.basename(config_file)
 86            try:
 87                config_id = int(filename.split('_')[0])  # Extract numeric prefix
 88                if config_id > max_id:
 89                    max_id = config_id
 90                    latest_config = config_file
 91            except (ValueError, IndexError):
 92                continue  # Skip non-conforming filenames
 93
 94        if latest_config:
 95            return latest_config
 96
 97        return os.path.join(pipeline_dir, "pipeline_config.json")
 98
 99    def _load_config_from_path(self, config_path):
100        """
101        Load configuration data from a specific JSON file.
102
103        Args:
104            config_path (str): Path to the configuration file.
105
106        Returns:
107            dict: Parsed configuration data, or an empty dict if load fails.
108        """
109        if not os.path.exists(config_path):
110            log.warning(f"Config file not found: {config_path}")
111            return {}
112
113        try:
114            with open(config_path, "r", encoding="utf-8") as f:
115                return json.load(f)
116        except (json.JSONDecodeError, IOError) as e:
117            log.error(f"Error loading config {config_path}: {e}")
118            return {}
119
120    def _load_config(self):
121        """Load the current configuration using the latest detected file."""
122        return self._load_config_from_path(self.config_path)
123
124    # ─────────────────────────────────────────────
125    # UI SETUP AND LAYOUT
126    # ─────────────────────────────────────────────
127    def _setup_ui(self):
128        """Construct the entire user interface for the review page."""
129        layout = self.main_layout
130
131        # Clear any existing widgets
132        while layout.count():
133            item = layout.takeAt(0)
134            widget = item.widget()
135            if widget:
136                widget.deleteLater()
137
138        # ── Header ──────────────────────────────
139        self.header = QLabel("Pipeline Configuration Review")
140        self.header.setAlignment(Qt.AlignmentFlag.AlignCenter)
141        self.header.setFont(QFont("Arial", 18, QFont.Weight.Bold))
142        layout.addWidget(self.header)
143
144        # ── Config file info ─────────────────────
145        self.config_info = QLabel(f"Reviewing: {os.path.basename(self.config_path)}")
146        self.config_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
147        self.config_info.setWordWrap(True)
148        self.config_info.setStyleSheet("""
149            font-size: 11px;
150            color: #666;
151            font-style: italic;
152            margin-bottom: 6px;
153        """)
154        layout.addWidget(self.config_info)
155
156        # ── Collapsible instructions ─────────────
157        info_frame = CollapsibleInfoFrame(self.context)
158        layout.addWidget(info_frame)
159
160        # ── Info label ──────────────────────────
161        self.info_label = QLabel(
162            "<strong>Click</strong> a frame to review file selections. "
163            "<strong>Save</strong> yellow frames after selection."
164        )
165        self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
166        self.info_label.setWordWrap(True)
167        self.info_label.setStyleSheet("""
168            color: #666;
169            font-size: 12px;
170            margin: 6px 0;
171        """)
172        layout.addWidget(self.info_label)
173
174        # ── Scroll area for patient panels ───────
175        scroll = QScrollArea()
176        scroll.setWidgetResizable(True)
177        scroll.setStyleSheet("QScrollArea { border: none; }")
178        layout.addWidget(scroll)
179
180        content = QWidget()
181        content_layout = QVBoxLayout(content)
182        content_layout.setContentsMargins(6, 6, 6, 6)
183        content_layout.setSpacing(8)
184        scroll.setWidget(content)
185
186        # ── Patient frames ───────────────────────
187        self.patient_widgets = {}
188
189        # Define expected data categories and file patterns
190        categories = {
191            "mri": [os.path.join(self.workspace_path, "{pid}", "anat", "*_flair.nii*")],
192            "mri_str": [os.path.join(self.workspace_path, "derivatives", "skullstrips", "{pid}", "anat", "*_brain.nii*")],
193            "pet": [os.path.join(self.workspace_path, "{pid}", "ses-01", "pet", "*_pet.nii*")],
194            "pet4d": [os.path.join(self.workspace_path, "{pid}", "ses-02", "pet", "*_pet.nii*")],
195            "tumor_mri": [
196                os.path.join(self.workspace_path, "derivatives", "manual_masks", "{pid}", "anat", "*_mask.nii*"),
197                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", "{pid}", "anat", "*_seg.nii*"),
198            ],
199        }
200
201        # Create collapsible frames for each patient
202        for patient_id, files in self.pipeline_config.items():
203            patient_patterns = {
204                cat: [pat.format(pid=patient_id) for pat in pats]
205                for cat, pats in categories.items()
206            }
207            multiple_choice = bool(files.get("need_revision", False))
208
209            frame = CollapsiblePatientFrame(
210                self.context,
211                patient_id,
212                files,
213                patient_patterns,
214                multiple_choice,
215                save_callback=self._save_single_patient,
216            )
217
218            self.patient_widgets[patient_id] = frame
219            content_layout.addWidget(frame)
220
221        content_layout.addStretch()
222
223    # ─────────────────────────────────────────────
224    # SAVE & UPDATE LOGIC
225    # ─────────────────────────────────────────────
226    def _save_single_patient(self, patient_id, files):
227        """
228        Save the configuration for a single patient.
229
230        Args:
231            patient_id (str): Patient identifier.
232            files (dict): Updated file paths and metadata.
233        """
234        self.pipeline_config[patient_id] = files
235        with open(self.config_path, "w", encoding="utf-8") as f:
236            json.dump(self.pipeline_config, f, indent=4)
237
238    def on_enter(self):
239        """
240        Hook called when the page becomes visible.
241
242        Checks for updated configuration files and refreshes the UI
243        if any changes are detected.
244        """
245        new_config_path = self._find_latest_config()
246        new_pipeline_config = self._load_config_from_path(new_config_path)
247
248        config_changed = (
249            new_config_path != self.config_path or
250            new_pipeline_config != self.pipeline_config
251        )
252
253        if config_changed:
254            log.info(f"Config changed, reloading UI: {os.path.basename(new_config_path)}")
255            self.config_path = new_config_path
256            self.pipeline_config = new_pipeline_config
257            self._setup_ui()
258        else:
259            log.debug("Config unchanged, keeping existing UI")
260
261    # ─────────────────────────────────────────────
262    # NAVIGATION HANDLERS
263    # ─────────────────────────────────────────────
264    def next(self, context):
265        """
266        Proceed to the next page, executing the pipeline if all configurations are valid.
267
268        Args:
269            context (dict): Shared context.
270
271        Returns:
272            Page: The next page instance, or self if configuration is incomplete.
273        """
274        # Check for patients requiring revision
275        unsaved_patients = [
276            pid for pid, files in self.pipeline_config.items()
277            if files.get("need_revision", False)
278        ]
279
280        if unsaved_patients:
281            # Warn the user about unsaved patients
282            from PyQt6.QtWidgets import QMessageBox
283            msg = QMessageBox()
284            msg.setIcon(QMessageBox.Icon.Warning)
285            msg.setWindowTitle(QCoreApplication.translate("PipelineReviewPage", "Configuration Incomplete"))
286            msg.setText(QCoreApplication.translate("PipelineReviewPage", "Some patients still require configuration review."))
287            msg.setInformativeText(
288                QCoreApplication.translate(
289                    "PipelineReviewPage",
290                    "Please review and save configuration for: {patients}"
291                ).format(patients=", ".join(unsaved_patients))
292            )
293            msg.exec()
294            return self  # Stay on current page
295
296        # Proceed to pipeline execution page
297        if not self.next_page:
298            self.next_page = PipelineExecutionPage(context, self)
299            self.context["history"].append(self.next_page)
300        self.next_page.on_enter()
301        return self.next_page
302
303    def back(self):
304        """
305        Navigate back to the previous page.
306
307        If the output folder for the current config does not exist,
308        the config file is deleted to keep the workspace clean.
309        """
310        if os.path.exists(self.config_path):
311            try:
312                config_filename = os.path.basename(self.config_path)
313                config_id = config_filename.split('_config.json')[0]
314                pipeline_dir = os.path.dirname(self.config_path)
315                output_folder_path = os.path.join(pipeline_dir, f"{config_id}_output")
316
317                if not os.path.exists(output_folder_path):
318                    os.remove(self.config_path)
319                    log.info(f"Removed unused config file: {self.config_path}")
320                else:
321                    log.info(f"Keeping config file: {self.config_path}")
322            except (OSError, IndexError, ValueError) as e:
323                log.error(f"Error processing config file {self.config_path}: {e}")
324        else:
325            log.error(f"Invalid config path: {self.config_path}")
326
327        if self.previous_page:
328            self.previous_page.on_enter()
329            return self.previous_page
330        return None
331
332    # ─────────────────────────────────────────────
333    # TRANSLATION
334    # ─────────────────────────────────────────────
335    def _translate_ui(self):
336        """Translate all static text for internationalization."""
337        self.header.setText(QCoreApplication.translate("PipelineReviewPage", "Pipeline Configuration Review"))
338        self.config_info.setText(
339            QCoreApplication.translate("PipelineReviewPage", "Reviewing: {0}").format(os.path.basename(self.config_path))
340        )
341        self.info_label.setText(
342            QCoreApplication.translate(
343                "PipelineReviewPage",
344                "<strong>Click</strong> a frame to review file selections. <strong>Save</strong> yellow frames after selection."
345            )
346        )
log = <Logger GliAAns-UI (WARNING)>
class PipelineReviewPage(page.Page):
 19class PipelineReviewPage(Page):
 20    """
 21    GUI page that allows users to review and adjust the configuration
 22    before running the medical imaging pipeline.
 23
 24    This page:
 25    - Loads the latest configuration JSON file.
 26    - Displays collapsible panels for each patient and data type.
 27    - Allows editing, saving, and validating configurations.
 28    - Transitions to the execution page once all data is ready.
 29    """
 30
 31    def __init__(self, context=None, previous_page=None):
 32        """
 33        Initialize the review page and build its UI.
 34
 35        Args:
 36            context (dict): Shared application context (workspace path, logger, signals, etc.).
 37            previous_page (Page): The previous page in the workflow.
 38        """
 39        super().__init__()
 40        self.context = context
 41        self.workspace_path = context["workspace_path"]
 42        self.previous_page = previous_page
 43        self.next_page = None
 44
 45        # Locate and load the latest configuration file
 46        self.config_path = self._find_latest_config()
 47        self.pipeline_config = self._load_config()
 48
 49        # Main layout container
 50        self.main_layout = QVBoxLayout(self)
 51
 52        # Build interface
 53        self._setup_ui()
 54
 55        # Apply translation and connect to language change signal (if available)
 56        self._translate_ui()
 57        if context and "language_changed" in context:
 58            context["language_changed"].connect(self._translate_ui)
 59
 60    # ─────────────────────────────────────────────
 61    # CONFIGURATION MANAGEMENT
 62    # ─────────────────────────────────────────────
 63    def _find_latest_config(self):
 64        """
 65        Find the most recent pipeline configuration file in the workspace.
 66
 67        Returns:
 68            str: Absolute path to the newest configuration JSON file.
 69        """
 70        pipeline_dir = os.path.join(self.workspace_path, "pipeline")
 71
 72        # Return a default path if the directory does not exist
 73        if not os.path.exists(pipeline_dir):
 74            return os.path.join(pipeline_dir, "pipeline_config.json")
 75
 76        # Locate all files matching the *_config.json pattern
 77        config_files = glob.glob(os.path.join(pipeline_dir, "*_config.json"))
 78
 79        if not config_files:
 80            return os.path.join(pipeline_dir, "pipeline_config.json")
 81
 82        # Identify the latest config file by its numeric ID prefix
 83        max_id = 0
 84        latest_config = None
 85        for config_file in config_files:
 86            filename = os.path.basename(config_file)
 87            try:
 88                config_id = int(filename.split('_')[0])  # Extract numeric prefix
 89                if config_id > max_id:
 90                    max_id = config_id
 91                    latest_config = config_file
 92            except (ValueError, IndexError):
 93                continue  # Skip non-conforming filenames
 94
 95        if latest_config:
 96            return latest_config
 97
 98        return os.path.join(pipeline_dir, "pipeline_config.json")
 99
100    def _load_config_from_path(self, config_path):
101        """
102        Load configuration data from a specific JSON file.
103
104        Args:
105            config_path (str): Path to the configuration file.
106
107        Returns:
108            dict: Parsed configuration data, or an empty dict if load fails.
109        """
110        if not os.path.exists(config_path):
111            log.warning(f"Config file not found: {config_path}")
112            return {}
113
114        try:
115            with open(config_path, "r", encoding="utf-8") as f:
116                return json.load(f)
117        except (json.JSONDecodeError, IOError) as e:
118            log.error(f"Error loading config {config_path}: {e}")
119            return {}
120
121    def _load_config(self):
122        """Load the current configuration using the latest detected file."""
123        return self._load_config_from_path(self.config_path)
124
125    # ─────────────────────────────────────────────
126    # UI SETUP AND LAYOUT
127    # ─────────────────────────────────────────────
128    def _setup_ui(self):
129        """Construct the entire user interface for the review page."""
130        layout = self.main_layout
131
132        # Clear any existing widgets
133        while layout.count():
134            item = layout.takeAt(0)
135            widget = item.widget()
136            if widget:
137                widget.deleteLater()
138
139        # ── Header ──────────────────────────────
140        self.header = QLabel("Pipeline Configuration Review")
141        self.header.setAlignment(Qt.AlignmentFlag.AlignCenter)
142        self.header.setFont(QFont("Arial", 18, QFont.Weight.Bold))
143        layout.addWidget(self.header)
144
145        # ── Config file info ─────────────────────
146        self.config_info = QLabel(f"Reviewing: {os.path.basename(self.config_path)}")
147        self.config_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
148        self.config_info.setWordWrap(True)
149        self.config_info.setStyleSheet("""
150            font-size: 11px;
151            color: #666;
152            font-style: italic;
153            margin-bottom: 6px;
154        """)
155        layout.addWidget(self.config_info)
156
157        # ── Collapsible instructions ─────────────
158        info_frame = CollapsibleInfoFrame(self.context)
159        layout.addWidget(info_frame)
160
161        # ── Info label ──────────────────────────
162        self.info_label = QLabel(
163            "<strong>Click</strong> a frame to review file selections. "
164            "<strong>Save</strong> yellow frames after selection."
165        )
166        self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
167        self.info_label.setWordWrap(True)
168        self.info_label.setStyleSheet("""
169            color: #666;
170            font-size: 12px;
171            margin: 6px 0;
172        """)
173        layout.addWidget(self.info_label)
174
175        # ── Scroll area for patient panels ───────
176        scroll = QScrollArea()
177        scroll.setWidgetResizable(True)
178        scroll.setStyleSheet("QScrollArea { border: none; }")
179        layout.addWidget(scroll)
180
181        content = QWidget()
182        content_layout = QVBoxLayout(content)
183        content_layout.setContentsMargins(6, 6, 6, 6)
184        content_layout.setSpacing(8)
185        scroll.setWidget(content)
186
187        # ── Patient frames ───────────────────────
188        self.patient_widgets = {}
189
190        # Define expected data categories and file patterns
191        categories = {
192            "mri": [os.path.join(self.workspace_path, "{pid}", "anat", "*_flair.nii*")],
193            "mri_str": [os.path.join(self.workspace_path, "derivatives", "skullstrips", "{pid}", "anat", "*_brain.nii*")],
194            "pet": [os.path.join(self.workspace_path, "{pid}", "ses-01", "pet", "*_pet.nii*")],
195            "pet4d": [os.path.join(self.workspace_path, "{pid}", "ses-02", "pet", "*_pet.nii*")],
196            "tumor_mri": [
197                os.path.join(self.workspace_path, "derivatives", "manual_masks", "{pid}", "anat", "*_mask.nii*"),
198                os.path.join(self.workspace_path, "derivatives", "deep_learning_seg", "{pid}", "anat", "*_seg.nii*"),
199            ],
200        }
201
202        # Create collapsible frames for each patient
203        for patient_id, files in self.pipeline_config.items():
204            patient_patterns = {
205                cat: [pat.format(pid=patient_id) for pat in pats]
206                for cat, pats in categories.items()
207            }
208            multiple_choice = bool(files.get("need_revision", False))
209
210            frame = CollapsiblePatientFrame(
211                self.context,
212                patient_id,
213                files,
214                patient_patterns,
215                multiple_choice,
216                save_callback=self._save_single_patient,
217            )
218
219            self.patient_widgets[patient_id] = frame
220            content_layout.addWidget(frame)
221
222        content_layout.addStretch()
223
224    # ─────────────────────────────────────────────
225    # SAVE & UPDATE LOGIC
226    # ─────────────────────────────────────────────
227    def _save_single_patient(self, patient_id, files):
228        """
229        Save the configuration for a single patient.
230
231        Args:
232            patient_id (str): Patient identifier.
233            files (dict): Updated file paths and metadata.
234        """
235        self.pipeline_config[patient_id] = files
236        with open(self.config_path, "w", encoding="utf-8") as f:
237            json.dump(self.pipeline_config, f, indent=4)
238
239    def on_enter(self):
240        """
241        Hook called when the page becomes visible.
242
243        Checks for updated configuration files and refreshes the UI
244        if any changes are detected.
245        """
246        new_config_path = self._find_latest_config()
247        new_pipeline_config = self._load_config_from_path(new_config_path)
248
249        config_changed = (
250            new_config_path != self.config_path or
251            new_pipeline_config != self.pipeline_config
252        )
253
254        if config_changed:
255            log.info(f"Config changed, reloading UI: {os.path.basename(new_config_path)}")
256            self.config_path = new_config_path
257            self.pipeline_config = new_pipeline_config
258            self._setup_ui()
259        else:
260            log.debug("Config unchanged, keeping existing UI")
261
262    # ─────────────────────────────────────────────
263    # NAVIGATION HANDLERS
264    # ─────────────────────────────────────────────
265    def next(self, context):
266        """
267        Proceed to the next page, executing the pipeline if all configurations are valid.
268
269        Args:
270            context (dict): Shared context.
271
272        Returns:
273            Page: The next page instance, or self if configuration is incomplete.
274        """
275        # Check for patients requiring revision
276        unsaved_patients = [
277            pid for pid, files in self.pipeline_config.items()
278            if files.get("need_revision", False)
279        ]
280
281        if unsaved_patients:
282            # Warn the user about unsaved patients
283            from PyQt6.QtWidgets import QMessageBox
284            msg = QMessageBox()
285            msg.setIcon(QMessageBox.Icon.Warning)
286            msg.setWindowTitle(QCoreApplication.translate("PipelineReviewPage", "Configuration Incomplete"))
287            msg.setText(QCoreApplication.translate("PipelineReviewPage", "Some patients still require configuration review."))
288            msg.setInformativeText(
289                QCoreApplication.translate(
290                    "PipelineReviewPage",
291                    "Please review and save configuration for: {patients}"
292                ).format(patients=", ".join(unsaved_patients))
293            )
294            msg.exec()
295            return self  # Stay on current page
296
297        # Proceed to pipeline execution page
298        if not self.next_page:
299            self.next_page = PipelineExecutionPage(context, self)
300            self.context["history"].append(self.next_page)
301        self.next_page.on_enter()
302        return self.next_page
303
304    def back(self):
305        """
306        Navigate back to the previous page.
307
308        If the output folder for the current config does not exist,
309        the config file is deleted to keep the workspace clean.
310        """
311        if os.path.exists(self.config_path):
312            try:
313                config_filename = os.path.basename(self.config_path)
314                config_id = config_filename.split('_config.json')[0]
315                pipeline_dir = os.path.dirname(self.config_path)
316                output_folder_path = os.path.join(pipeline_dir, f"{config_id}_output")
317
318                if not os.path.exists(output_folder_path):
319                    os.remove(self.config_path)
320                    log.info(f"Removed unused config file: {self.config_path}")
321                else:
322                    log.info(f"Keeping config file: {self.config_path}")
323            except (OSError, IndexError, ValueError) as e:
324                log.error(f"Error processing config file {self.config_path}: {e}")
325        else:
326            log.error(f"Invalid config path: {self.config_path}")
327
328        if self.previous_page:
329            self.previous_page.on_enter()
330            return self.previous_page
331        return None
332
333    # ─────────────────────────────────────────────
334    # TRANSLATION
335    # ─────────────────────────────────────────────
336    def _translate_ui(self):
337        """Translate all static text for internationalization."""
338        self.header.setText(QCoreApplication.translate("PipelineReviewPage", "Pipeline Configuration Review"))
339        self.config_info.setText(
340            QCoreApplication.translate("PipelineReviewPage", "Reviewing: {0}").format(os.path.basename(self.config_path))
341        )
342        self.info_label.setText(
343            QCoreApplication.translate(
344                "PipelineReviewPage",
345                "<strong>Click</strong> a frame to review file selections. <strong>Save</strong> yellow frames after selection."
346            )
347        )

GUI page that allows users to review and adjust the configuration before running the medical imaging pipeline.

This page:

  • Loads the latest configuration JSON file.
  • Displays collapsible panels for each patient and data type.
  • Allows editing, saving, and validating configurations.
  • Transitions to the execution page once all data is ready.
PipelineReviewPage(context=None, previous_page=None)
31    def __init__(self, context=None, previous_page=None):
32        """
33        Initialize the review page and build its UI.
34
35        Args:
36            context (dict): Shared application context (workspace path, logger, signals, etc.).
37            previous_page (Page): The previous page in the workflow.
38        """
39        super().__init__()
40        self.context = context
41        self.workspace_path = context["workspace_path"]
42        self.previous_page = previous_page
43        self.next_page = None
44
45        # Locate and load the latest configuration file
46        self.config_path = self._find_latest_config()
47        self.pipeline_config = self._load_config()
48
49        # Main layout container
50        self.main_layout = QVBoxLayout(self)
51
52        # Build interface
53        self._setup_ui()
54
55        # Apply translation and connect to language change signal (if available)
56        self._translate_ui()
57        if context and "language_changed" in context:
58            context["language_changed"].connect(self._translate_ui)

Initialize the review page and build its UI.

Args: context (dict): Shared application context (workspace path, logger, signals, etc.). previous_page (Page): The previous page in the workflow.

context
workspace_path
previous_page
next_page
config_path
pipeline_config
main_layout
def on_enter(self):
239    def on_enter(self):
240        """
241        Hook called when the page becomes visible.
242
243        Checks for updated configuration files and refreshes the UI
244        if any changes are detected.
245        """
246        new_config_path = self._find_latest_config()
247        new_pipeline_config = self._load_config_from_path(new_config_path)
248
249        config_changed = (
250            new_config_path != self.config_path or
251            new_pipeline_config != self.pipeline_config
252        )
253
254        if config_changed:
255            log.info(f"Config changed, reloading UI: {os.path.basename(new_config_path)}")
256            self.config_path = new_config_path
257            self.pipeline_config = new_pipeline_config
258            self._setup_ui()
259        else:
260            log.debug("Config unchanged, keeping existing UI")

Hook called when the page becomes visible.

Checks for updated configuration files and refreshes the UI if any changes are detected.

def next(self, context):
265    def next(self, context):
266        """
267        Proceed to the next page, executing the pipeline if all configurations are valid.
268
269        Args:
270            context (dict): Shared context.
271
272        Returns:
273            Page: The next page instance, or self if configuration is incomplete.
274        """
275        # Check for patients requiring revision
276        unsaved_patients = [
277            pid for pid, files in self.pipeline_config.items()
278            if files.get("need_revision", False)
279        ]
280
281        if unsaved_patients:
282            # Warn the user about unsaved patients
283            from PyQt6.QtWidgets import QMessageBox
284            msg = QMessageBox()
285            msg.setIcon(QMessageBox.Icon.Warning)
286            msg.setWindowTitle(QCoreApplication.translate("PipelineReviewPage", "Configuration Incomplete"))
287            msg.setText(QCoreApplication.translate("PipelineReviewPage", "Some patients still require configuration review."))
288            msg.setInformativeText(
289                QCoreApplication.translate(
290                    "PipelineReviewPage",
291                    "Please review and save configuration for: {patients}"
292                ).format(patients=", ".join(unsaved_patients))
293            )
294            msg.exec()
295            return self  # Stay on current page
296
297        # Proceed to pipeline execution page
298        if not self.next_page:
299            self.next_page = PipelineExecutionPage(context, self)
300            self.context["history"].append(self.next_page)
301        self.next_page.on_enter()
302        return self.next_page

Proceed to the next page, executing the pipeline if all configurations are valid.

Args: context (dict): Shared context.

Returns: Page: The next page instance, or self if configuration is incomplete.

def back(self):
304    def back(self):
305        """
306        Navigate back to the previous page.
307
308        If the output folder for the current config does not exist,
309        the config file is deleted to keep the workspace clean.
310        """
311        if os.path.exists(self.config_path):
312            try:
313                config_filename = os.path.basename(self.config_path)
314                config_id = config_filename.split('_config.json')[0]
315                pipeline_dir = os.path.dirname(self.config_path)
316                output_folder_path = os.path.join(pipeline_dir, f"{config_id}_output")
317
318                if not os.path.exists(output_folder_path):
319                    os.remove(self.config_path)
320                    log.info(f"Removed unused config file: {self.config_path}")
321                else:
322                    log.info(f"Keeping config file: {self.config_path}")
323            except (OSError, IndexError, ValueError) as e:
324                log.error(f"Error processing config file {self.config_path}: {e}")
325        else:
326            log.error(f"Invalid config path: {self.config_path}")
327
328        if self.previous_page:
329            self.previous_page.on_enter()
330            return self.previous_page
331        return None

Navigate back to the previous page.

If the output folder for the current config does not exist, the config file is deleted to keep the workspace clean.