"""Module to define main window appearance, functions and settings.
Initialize the software appearance and defines interactions with the user.
:Contains:
:Class:
- MainWindow
- _ProcDeleter
"""
##########################################################################
# Populse_mia - Copyright (C) IRMaGe/CEA, 2018
# Distributed under the terms of the CeCILL license, as published by
# the CEA-CNRS-INRIA. Refer to the LICENSE file or to
# http://www.cecill.info/licences/Licence_CeCILL_V2.1-en.html
# for details.
##########################################################################
import glob
import logging
import os
import shutil
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime
from os.path import expanduser
import yaml
from packaging import version
from PyQt5.QtCore import QCoreApplication, Qt
# PyQt5 imports
from PyQt5.QtGui import QCursor, QIcon
from PyQt5.QtWidgets import (
QAction,
QApplication,
QMainWindow,
QMenu,
QMessageBox,
QPushButton,
QTabWidget,
QVBoxLayout,
QWidget,
)
import populse_mia.data_manager.data_loader as data_loader
from populse_mia.data_manager import (
CLINICAL_TAGS,
COLLECTION_CURRENT,
TAG_HISTORY,
)
from populse_mia.data_manager.project import Project
# Populse_MIA imports
from populse_mia.data_manager.project_properties import SavedProjects
from populse_mia.software_properties import Config
from populse_mia.user_interface.data_browser.data_browser import DataBrowser
from populse_mia.user_interface.data_viewer.data_viewer_tab import (
DataViewerTab,
)
from populse_mia.user_interface.pipeline_manager.pipeline_manager_tab import (
PipelineManagerTab,
)
from populse_mia.user_interface.pipeline_manager.process_library import (
InstallProcesses,
PackageLibraryDialog,
)
from populse_mia.user_interface.pop_ups import (
PopUpDeletedProject,
PopUpDeleteProject,
PopUpNewProject,
PopUpOpenProject,
PopUpPreferences,
PopUpProperties,
PopUpQuit,
PopUpSaveProjectAs,
PopUpSeeAllProjects,
)
console_shell_running = False
_ipsubprocs_lock = threading.RLock()
_ipsubprocs = []
logger = logging.getLogger(__name__)
class _ProcDeleter(threading.Thread):
"""
A helper class to manage the lifecycle of a subprocess.
This class is used internally by `MainWindow.open_shell()` to handle
subprocesses in a thread-safe manner. It ensures proper cleanup of the
subprocess when it is no longer needed and updates global state
variables as required.
:attr o (subprocess.Popen): The subprocess object to manage.
:attr console (bool): Indicates if the subprocess is associated with
a console.
:methods:
__del__(): Ensures the subprocess is terminated and updates global
state variables related to the subprocess.
run(): Waits for the subprocess to complete and cleans up global
references to it.
"""
def __init__(self, o, console=False):
"""
Initializes the _ProcDeleter thread with the given subprocess.
:param o (subprocess.Popen): The subprocess object to be managed by
this thread.
:param console (bool): Indicates if the subprocess is associated
with a console.
"""
super().__init__()
self.o = o
self.console = console
def __del__(self):
"""
Ensures proper cleanup when the _ProcDeleter instance is deleted.
This method attempts to terminate the managed subprocess (`o`).
If the subprocess is associated with a console, it also updates
the global `console_shell_running` variable to indicate that the
console is no longer active.
"""
try:
self.o.kill()
except Exception as e:
logger.warning(f"Failed to kill subprocess: {e}")
if self.console:
global console_shell_running
console_shell_running = False
def run(self):
"""
Runs the thread to wait for the subprocess to finish.
This method waits for the subprocess to terminate by calling
`communicate` on the managed subprocess object. It then removes
itself from the global list `_ipsubprocs` in a thread-safe manner.
"""
try:
self.o.communicate()
except Exception as e:
logger.warning(f"Exception in subprocess communication: {e}")
with _ipsubprocs_lock:
try:
_ipsubprocs.remove(self)
except ValueError:
logger.warning(
"Attempted to remove a non-existent "
"subprocess from _ipsubprocs."
)
[docs]
class MainWindow(QMainWindow):
"""Initialize software appearance and define interactions with the user.
.. Methods:
- __init__ : Initialise the object MainWindow
- add_clinical_tags: Add the clinical tags to the database and the
data browser
- check_database: Check if files in database have been modified or
removed since they have been converted for the
first time
- check_unsaved_modifications: Check if there are differences between
the current project and the database
- closeEvent: Override the closing event to check if there are
unsaved modifications
- create_project_pop_up: Create a new project
- create_view_actions: Create the actions in each menu
- create_view_menus: Create the menu-bar
- create_view_window: Create the main window view
- create_tabs: Create the tabs
- credits: Open the credits in a web browser
- del_clinical_tags: Remove the clinical tags to the database and the
data browser
- delete_project: Open a project and updates the recent projects list
- documentation: Open the documentation in a web browser
- get_controller_version: Returns controller_version_changed attribute
- import_data: Call the import software (MRI File Manager)
- install_processes_pop_up: Open the install processes pop-up
- last_window_closed: Force exit the event loop after ipython console
is closed
- open_project_pop_up: Open a pop-up to open a project and updates
the recent projects
- open_recent_project: Open a recent project
- open_shell: Open a Qt console shell with an IPython kernel seeing
the program internals.
- package_library_pop_up: Open the package library pop-up
- project_properties_pop_up: Open the project properties pop-up
- redo: Redo the last action made by the user
- remove_raw_files_useless: Remove the useless raw files of the
current project
- run_ipconsole_kernel: Starts and initializes an IPython kernel with
support for a Qt-based GUI.
- save: Save either the current project or the current pipeline
- save_as: Save either the current project or the current pipeline
under a new name
- save_project_as: Open a pop-up to save the current project as
- saveChoice: Checks if the project needs to be saved as or just saved
- see_all_projects: Open a pop-up to show the recent projects
- set_controller_version: Reverses controller_version_changed attribute
- setup_menu_actions: Initialize menu actions
- setup_window_size: Set the window size and maximize if needed
- software_preferences_pop_up: Open the Mia preferences pop-up
- switch_project: Switches project if it's possible
- tab_changed: Method called when the tab is changed
- undo: Undoes the last action made by the user
- update_project: Update the project once the database has been
updated
- update_recent_projects_actions: Update the list of recent projects
"""
[docs]
def __init__(self, project, test=False, deleted_projects=None):
"""
Main window class, initializes the software appearance and defines
interactions with the user.
:param project: Current project in the software.
:param test: Boolean indicating if the widget is launched from unit
tests or not.
:param deleted_projects: Projects that have been deleted.
"""
super().__init__()
QApplication.restoreOverrideCursor()
# Associate methods and instance to call them from anywhere
QCoreApplication.instance().title = self.windowTitle
QCoreApplication.instance().set_title = self.setWindowTitle
if deleted_projects:
self.msg = PopUpDeletedProject(deleted_projects)
self.config = Config()
self.config.setSourceImageDir(
os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
"sources_images",
)
)
self.windowName = "MIA - Multiparametric Image Analysis"
self.projectName = "Unnamed project"
self.project = project
self.test = test
self.saved_projects = SavedProjects()
self.saved_projects_list = self.saved_projects.pathsList
self.saved_projects_actions = []
self.controller_version_changed = False
# Define main window view
self.create_view_window()
# Initialize menu
self.menu_file = self.menuBar().addMenu("File")
self.menu_edition = self.menuBar().addMenu("Edit")
self.menu_help = self.menuBar().addMenu("Help")
self.menu_about = self.menuBar().addMenu("About")
self.menu_more = self.menuBar().addMenu("More")
self.menu_install_process = QMenu("Install processes", self)
self.menu_saved_projects = QMenu("Saved projects", self)
# Initialize tabs
self.tabs = QTabWidget()
self.data_browser = DataBrowser(self.project, self)
self.data_viewer = DataViewerTab(self)
self.pipeline_manager = PipelineManagerTab(self.project, [], self)
self.centralWindow = QWidget()
# Initialize menu actions
sources_images_dir = self.config.getSourceImageDir()
self.setup_menu_actions(sources_images_dir)
# Connect actions & menus views
self.create_view_actions()
self.create_view_menus()
# Create Tabs
self.create_tabs()
self.setCentralWidget(self.centralWindow)
# Set window size and maximize if needed
self.setup_window_size()
[docs]
def add_clinical_tags(self):
"""Add the clinical tags to the database and the data browser."""
added_tags = self.project.add_clinical_tags()
for tag in added_tags:
column = self.data_browser.table_data.get_index_insertion(tag)
self.data_browser.table_data.add_column(column, tag)
[docs]
def check_database(self):
"""
Check if files in database have been modified since first import.
"""
if self.project is None:
return
QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
logger.info("Verify scans...")
t0 = time.time()
problem_list = data_loader.verify_scans(self.project)
logger.info(f"check time: {time.time() - t0}")
QApplication.restoreOverrideCursor()
# Message if invalid files
if problem_list:
str_msg = "".join(f"{element}\n\n" for element in problem_list)
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText(
"These files have been modified or removed since "
"they have been converted for the first time:"
)
msg.setInformativeText(str_msg)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
[docs]
def check_unsaved_modifications(self):
"""
Check if there are differences between the current project and the
database.
:return (bool): True if there are unsaved modifications,
False otherwise
"""
if self.project is None:
return True
if self.project.isTempProject:
with self.project.database.data() as database_data:
return bool(
database_data.get_document_names(COLLECTION_CURRENT)
)
return self.project.hasUnsavedModifications()
[docs]
def closeEvent(self, event):
"""
Override the QWidget closing event to check if there are unsaved
modifications.
:param event: closing event
"""
if not self.check_unsaved_modifications() or self.test:
can_exit = True
else:
self.pop_up_close = PopUpQuit(self.project)
self.pop_up_close.save_as_signal.connect(self.saveChoice)
self.pop_up_close.exec()
can_exit = self.pop_up_close.can_exit()
if can_exit:
if self.pipeline_manager.init_clicked:
if self.project is not None:
self.project.unsaveModifications()
for brick in self.pipeline_manager.brick_list:
if self.project is not None:
self.data_browser.table_data.delete_from_brick(brick)
# Clean up
config = Config()
opened_projects = config.get_opened_projects()
folder = getattr(getattr(self, "project", None), "folder", None)
if folder and folder in opened_projects:
opened_projects.remove(self.project.folder)
config.set_opened_projects(opened_projects)
# Change controller version if needed
if self.controller_version_changed:
self.msg = QMessageBox()
self.msg.setIcon(QMessageBox.Warning)
self.msg.setText("Controller version change")
self.msg.setInformativeText(
f"A change of controller version, from "
f"{'V1' if config.isControlV1() else 'V2'} to "
f"{'V2' if config.isControlV1() else 'V1'}, "
f"is planned for next start-up. Do you confirm that "
f"you would like to perform this "
f"change?"
)
self.msg.setWindowTitle("Warning")
self.msg.setStandardButtons(
QMessageBox.Yes | QMessageBox.Cancel
)
return_value = self.msg.exec()
if return_value == QMessageBox.Yes:
config.setControlV1(not config.isControlV1())
self.msg.close()
config.saveConfig()
if self.project is not None:
self.remove_raw_files_useless()
event.accept()
else:
event.ignore()
if self.data_browser.viewer is not None:
self.data_browser.viewer.clear()
self.data_browser.viewer = None
if self.data_viewer:
self.data_viewer.clear()
[docs]
def create_project_pop_up(self):
"""Create a new project."""
if self.check_unsaved_modifications():
self.pop_up_close = PopUpQuit(self.project)
self.pop_up_close.save_as_signal.connect(self.saveChoice)
self.pop_up_close.exec()
can_switch = self.pop_up_close.can_exit()
else:
can_switch = True
if can_switch:
# Opens a pop-up when the 'New project' action is clicked and
# updates the recent projects
try:
self.exPopup = PopUpNewProject()
except Exception as e:
logger.warning(f"Create_project_pop_up: {e}")
self.msg = QMessageBox()
self.msg.setIcon(QMessageBox.Critical)
self.msg.setText("Invalid projects folder path")
self.msg.setInformativeText(
"The projects folder path in Mia preferences is invalid!"
)
self.msg.setWindowTitle("Error")
yes_button = self.msg.addButton(
"Open Mia preferences", QMessageBox.YesRole
)
self.msg.addButton(QMessageBox.Ok)
self.msg.exec()
if self.msg.clickedButton() == yes_button:
self.software_preferences_pop_up()
self.msg.close()
else:
if self.exPopup.exec():
self.exPopup.get_filename(self.exPopup.selectedFiles())
file_name = self.exPopup.relative_path
# Removing the old project from the list of
# currently opened projects
config = Config()
opened_projects = config.get_opened_projects()
opened_projects.remove(self.project.folder)
config.set_opened_projects(opened_projects)
config.saveConfig()
# We remove the useless files from the old project
self.remove_raw_files_useless()
self.project = Project(self.exPopup.relative_path, True)
self.update_project(
file_name
) # project updated everywhere
[docs]
def create_view_actions(self):
"""Create the actions and their shortcuts in each menu"""
self.action_create.setShortcut("Ctrl+N")
self.action_open.setShortcut("Ctrl+O")
self.action_save.setShortcut("Ctrl+S")
self.addAction(self.action_save)
self.action_save_as.setShortcut("Ctrl+Shift+S")
self.addAction(self.action_save_as)
self.addAction(self.action_delete)
self.action_import.setShortcut("Ctrl+I")
for i in range(self.config.get_max_projects()):
self.saved_projects_actions.append(
QAction(
self, visible=False, triggered=self.open_recent_project
)
)
if Config().get_user_mode() is True:
self.action_delete_project.setDisabled(True)
else:
self.action_delete_project.setEnabled(True)
self.action_exit.setShortcut("Ctrl+W")
self.action_undo.setShortcut("Ctrl+Z")
self.action_redo.setShortcut("Ctrl+Y")
# Connection of the several triggered signals of the actions to some
# other methods
self.action_create.triggered.connect(self.create_project_pop_up)
self.action_open.triggered.connect(self.open_project_pop_up)
self.action_exit.triggered.connect(self.close)
self.action_check_database.triggered.connect(self.check_database)
self.action_open_shell.triggered.connect(self.open_shell)
self.action_save.triggered.connect(self.save)
self.action_save_as.triggered.connect(self.save_as)
self.action_delete.triggered.connect(self.delete_project)
self.action_import.triggered.connect(self.import_data)
self.action_see_all_projects.triggered.connect(self.see_all_projects)
self.action_project_properties.triggered.connect(
self.project_properties_pop_up
)
self.action_software_preferences.triggered.connect(
self.software_preferences_pop_up
)
self.action_package_library.triggered.connect(
self.package_library_pop_up
)
self.action_undo.triggered.connect(self.undo)
self.action_redo.triggered.connect(self.redo)
self.action_documentation.triggered.connect(self.documentation)
self.action_credits.triggered.connect(self.credits)
self.action_install_processes_folder.triggered.connect(
lambda: self.install_processes_pop_up(folder=True)
)
self.action_install_processes_zip.triggered.connect(
lambda: self.install_processes_pop_up(folder=False)
)
[docs]
def create_view_menus(self):
"""Create the menu-bar view."""
self.menu_more.addMenu(self.menu_install_process)
# Actions in the "File" menu
self.menu_file.addAction(self.action_create)
self.menu_file.addAction(self.action_open)
self.menu_file.addAction(self.action_check_database)
self.action_save_project.triggered.connect(self.saveChoice)
self.action_save_project_as.triggered.connect(self.save_project_as)
self.action_delete_project.triggered.connect(self.delete_project)
self.menu_file.addSeparator()
self.menu_file.addAction(self.action_import)
self.menu_file.addSeparator()
self.menu_file.addMenu(self.menu_saved_projects)
for i in range(self.config.get_max_projects()):
self.menu_saved_projects.addAction(self.saved_projects_actions[i])
self.menu_saved_projects.addSeparator()
self.menu_saved_projects.addAction(self.action_see_all_projects)
self.menu_file.addSeparator()
self.menu_file.addAction(self.action_software_preferences)
self.menu_file.addAction(self.action_project_properties)
self.menu_file.addAction(self.action_package_library)
self.menu_file.addSeparator()
self.menu_file.addAction(self.action_open_shell)
self.menu_file.addSeparator()
self.menu_file.addAction(self.action_exit)
self.update_recent_projects_actions()
# Actions in the "Edition" menu
self.menu_edition.addAction(self.action_undo)
self.menu_edition.addAction(self.action_redo)
# Actions in the "Help" menu
self.menu_help.addAction(self.action_documentation)
self.menu_help.addAction(self.action_credits)
# Actions in the "More > Install processes" menu
self.menu_install_process.addAction(
self.action_install_processes_folder
)
self.menu_install_process.addAction(self.action_install_processes_zip)
[docs]
def create_view_window(self):
"""Create the main window view."""
sources_images_dir = Config().getSourceImageDir()
app_icon = QIcon(
os.path.join(sources_images_dir, "Logo_populse_mia_LR.jpeg")
)
self.setWindowIcon(app_icon)
background_color = self.config.getBackgroundColor()
text_color = self.config.getTextColor()
if not self.config.get_user_mode():
self.windowName = f"{self.windowName} (Admin mode)"
self.windowName = f"{self.windowName} - "
self.setStyleSheet(
f"background-color:{background_color};color:{text_color};"
)
self.statusBar().showMessage(
"Please create a new project (Ctrl+N) or "
"open an existing project (Ctrl+O)"
)
self.setWindowTitle(f"{self.windowName}{self.projectName}")
[docs]
def create_tabs(self):
"""
Create the tabs and initializes the DataBrowser and PipelineManager
classes.
"""
self.config = Config()
self.tabs.setAutoFillBackground(False)
self.tabs.setStyleSheet("QTabBar{font-size:16pt;text-align: center}")
self.tabs.setMovable(True)
self.tabs.addTab(self.data_browser, "Data Browser")
self.tabs.addTab(self.data_viewer, "Data Viewer")
self.tabs.addTab(self.pipeline_manager, "Pipeline Manager")
self.tabs.currentChanged.connect(self.tab_changed)
vertical_layout = QVBoxLayout()
vertical_layout.addWidget(self.tabs)
self.centralWindow.setLayout(vertical_layout)
[docs]
def credits(self):
"""Open the credits in a web browser"""
webbrowser.open(
"https://github.com/populse/populse_mia/graphs/contributors"
)
[docs]
def del_clinical_tags(self):
"""Remove the clinical tags to the database and the data browser"""
removed_tags = self.project.del_clinical_tags()
for tag in removed_tags:
self.data_browser.table_data.removeColumn(
self.data_browser.table_data.get_tag_column(tag)
)
[docs]
def delete_project(self):
"""
Open a pop-up to open a project and updates the recent projects list.
"""
try:
self.exPopup = PopUpDeleteProject(self)
except Exception as e:
logger.warning(f"Delete_project: {e}")
self.msg = QMessageBox()
self.msg.setIcon(QMessageBox.Critical)
self.msg.setText("Invalid projects folder path")
self.msg.setInformativeText(
"The projects folder path in Mia preferences is invalid!"
)
self.msg.setWindowTitle("Error")
yes_button = self.msg.addButton(
"Open Mia preferences", QMessageBox.YesRole
)
self.msg.addButton(QMessageBox.Ok)
self.msg.exec()
if self.msg.clickedButton() == yes_button:
self.software_preferences_pop_up()
self.msg.close()
else:
self.exPopup.exec()
[docs]
@staticmethod
def documentation():
"""Open the documentation in a web browser."""
webbrowser.open(
"https://populse.github.io/populse_mia/html/index.html"
)
[docs]
def get_controller_version(self):
"""Gives the value of the controller_version_changed attribute.
:return: Boolean
"""
return self.controller_version_changed
[docs]
def import_data(self):
"""
Import MRI data using the MRI File Manager and load it into
the database.
This method performs the following steps:
1. Launches the MRI conversion software to convert MRI files to
Nifti/JSON format
2. Attempts import with maximum heap size of 4096M, falls back to
1024M if needed
3. Updates the database with newly imported scans
4. Refreshes the data browser UI with new scan information
"""
# Opens the conversion software to convert the MRI files in Nifti/Json
config = Config()
home = expanduser("~")
export_nifti_path = os.path.join(
self.project.folder, "data", "raw_data"
)
logger.info("Starting MRI conversion process...")
try:
# Xmxsize: Specifies the maximum size (in bytes) of the memory
# allocation pool in bytes
# Start with 4096M
code_exit = subprocess.call(
[
"java",
"-Xmx4096M",
"-jar",
config.get_mri_conv_path(),
f"[ProjectsDir] {home}",
f"[ExportNifti] {export_nifti_path}",
"[ExportToMIA] PatientName-StudyName-"
"CreationDate-SeqNumber-Protocol-"
"SequenceName-AcquisitionTime",
"CloseAfterExport",
"[ExportOptions] 00013",
]
)
if code_exit != 0 and code_exit != 100:
raise ValueError("mri_conv did not run properly!")
except ValueError:
logger.warning(
"Mri_conv: Test with lower maximum heap "
"size (4096M -> 1024M)..."
)
export_nifti_path = os.path.join(
self.project.folder, "data", "raw_data"
)
code_exit = subprocess.call(
[
"java",
"-Xmx1024M",
"-jar",
config.get_mri_conv_path(),
f"[ProjectsDir] {home}",
f"[ExportNifti] {export_nifti_path}",
"[ExportToMIA] PatientName-StudyName-"
"CreationDate-SeqNumber-Protocol-"
"SequenceName-AcquisitionTime",
"CloseAfterExport",
"[ExportOptions] 00013",
]
)
# 'NoLogExport' if we don't want log export
if code_exit == 0:
# Database filled
new_scans = data_loader.read_log(self.project, self)
# Table updated
with self.project.database.data() as database_data:
documents = database_data.get_document_names(
COLLECTION_CURRENT
)
self.data_browser.table_data.scans_to_visualize = documents
self.data_browser.table_data.scans_to_search = documents
self.data_browser.table_data.add_columns()
self.data_browser.table_data.fill_headers()
self.data_browser.table_data.add_rows(new_scans)
self.data_browser.reset_search_bar()
self.data_browser.frame_advanced_search.setHidden(True)
self.data_browser.advanced_search.rows = []
self.project.unsavedModifications = True
elif code_exit == 100: # User only close mri_conv and do nothing
pass
else:
logger.warning(
"Mri_conv, did not work properly. Current absolute"
" path to MRIManager.jar defined in File > MIA Preferences:"
)
logger.warning(f"{config.get_mri_conv_path}")
if not os.path.isfile(config.get_mri_conv_path()):
mssgText = (
f"Warning: mri_conv did not work properly. The "
f"current absolute path to MRIManager.jar doesn't "
f"seem to be correctly defined.\nCurrent absolute "
f"path to MRIManager.jar defined in\nFile > MIA "
f"Preferences:\n{config.get_mri_conv_path()}"
)
else:
mssgText = (
f"Warning : mri_conv did not work properly. Please "
f"check if the currently installed mri_conv Java "
f"ARchive is not corrupted.\nCurrent absolute path "
f"to MRIManager.jar defined in\nFile > MIA "
f"Preferences:\n{config.get_mri_conv_path()}"
)
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setWindowTitle("populse_mia - Warning: Data import issue!")
msg.setText(mssgText)
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
[docs]
def install_processes_pop_up(self, folder=False):
"""Open the install processes pop-up.
:param folder: boolean, True if installing from a folder
"""
self.pop_up_install_processes = InstallProcesses(self, folder=folder)
self.pop_up_install_processes.show()
self.pop_up_install_processes.process_installed.connect(
self.pipeline_manager.processLibrary.update_process_library
)
self.pop_up_install_processes.process_installed.connect(
self.pipeline_manager.processLibrary.pkg_library.update_config
)
[docs]
@staticmethod
def last_window_closed():
"""Force exit the event loop after ipython console is closed.
If the ipython console has been run, something prevents Qt from
quitting after the window is closed. The cause is not known yet.
So: force exit the event loop.
"""
from soma.qt_gui.qt_backend import Qt
Qt.QTimer.singleShot(10, Qt.qApp.exit)
[docs]
def open_project_pop_up(self):
"""
Open a dialog to select and open a project, updating recent
projects list.
This method handles:
1. Checking for unsaved modifications in current project
2. Opening project selection dialog
3. Validating project path
4. Switching to new project
5. Updating clinical mode based on database fields
6. Updating database paths if project is external
"""
# Ui_Dialog() is defined in pop_ups.py
# We check for unsaved modifications
if self.check_unsaved_modifications():
# If there are unsaved modifications, we ask the user what he
# wants to do
self.pop_up_close = PopUpQuit(self.project)
self.pop_up_close.save_as_signal.connect(self.saveChoice)
self.pop_up_close.exec()
can_switch = self.pop_up_close.can_exit()
else:
can_switch = True
# We can open a new project
if can_switch:
try:
self.exPopup = PopUpOpenProject()
except Exception as e:
logger.warning(f"Open_project_pop_up: {e}")
self.msg = QMessageBox()
self.msg.setIcon(QMessageBox.Critical)
self.msg.setText("Invalid projects folder path")
self.msg.setInformativeText(
"The projects folder path in Mia preferences is invalid!"
)
self.msg.setWindowTitle("Error")
yes_button = self.msg.addButton(
"Open Mia preferences", QMessageBox.YesRole
)
self.msg.addButton(QMessageBox.Ok)
self.msg.exec()
if self.msg.clickedButton() == yes_button:
self.software_preferences_pop_up()
self.msg.close()
else:
if self.exPopup.exec():
project_name = self.exPopup.selectedFiles()
self.exPopup.get_filename(project_name)
project_name = self.exPopup.relative_path
self.data_browser.data_sent = False
# We switch the project
self.switch_project(project_name, self.exPopup.name)
with self.project.database.data() as database_data:
field_names = database_data.get_field_names(
COLLECTION_CURRENT
)
if all(ele in field_names for ele in CLINICAL_TAGS):
Config().set_clinical_mode(True)
else:
Config().set_clinical_mode(False)
# Update the history and brick tables in the newly opened
# project, if it comes from outside.
path_name = os.path.dirname(
os.path.abspath(os.path.normpath(project_name))
)
projectsPath = os.path.abspath(
self.config.get_projects_save_path()
)
if path_name != projectsPath:
self.project.update_db_for_paths(path_name)
[docs]
def open_recent_project(self):
"""Open a recent project."""
# We check for unsaved modifications
if self.check_unsaved_modifications():
# If there are unsaved modifications, we ask the user what he
# wants to do
self.pop_up_close = PopUpQuit(self.project)
self.pop_up_close.save_as_signal.connect(self.saveChoice)
self.pop_up_close.exec()
can_switch = self.pop_up_close.can_exit()
else:
can_switch = True
# We can open a new project
if can_switch:
action = self.sender()
if action:
project_name = action.data()
entire_path = os.path.abspath(project_name)
path, name = os.path.split(entire_path)
relative_path = os.path.relpath(project_name)
self.switch_project(relative_path, name)
# We switch the project
with self.project.database.data() as database_data:
field_names = database_data.get_field_names(
COLLECTION_CURRENT
)
documents = database_data.get_document_names(
COLLECTION_CURRENT
)
self.data_viewer.set_documents(self.project, documents)
if all(ele in field_names for ele in CLINICAL_TAGS):
Config().set_clinical_mode(True)
else:
Config().set_clinical_mode(False)
[docs]
def open_shell(self):
"""
Open a Qt console shell with an IPython kernel seeing the program
internals.
"""
from soma.qt_gui import qt_backend
ipfunc = None
mode = "qtconsole"
logger.info("StartShell...")
try:
# to check it is installed
import jupyter_core.application # noqa: F401
import qtconsole # noqa: F401
ipfunc = (
"from jupyter_core import application; "
"app = application.JupyterApp(); app.initialize(); app.start()"
)
except ImportError:
logger.warning("Failed to run Qt console...")
return
if ipfunc:
import soma.subprocess
ipConsole = self.run_ipconsole_kernel(mode)
if ipConsole:
qt_api = qt_backend.get_qt_backend()
qt_apis = {
"PyQt4": "pyqt",
"PyQt5": "pyqt5",
"PySide": "pyside",
}
qt_api_code = qt_apis.get(qt_api, "pyq5t")
cmd = [
sys.executable,
"-c",
f'import os; os.environ["QT_API"] = '
f'"{qt_api_code}"; {ipfunc}',
mode,
"--existing",
f"--shell={ipConsole.shell_port}",
f"--iopub={ipConsole.iopub_port}",
f"--stdin={ipConsole.stdin_port}",
f"--hb={ipConsole.hb_port}",
]
sp = soma.subprocess.Popen(cmd)
pd = _ProcDeleter(sp)
with _ipsubprocs_lock:
_ipsubprocs.append(pd)
pd.start()
# hack the lastWindowClosed event because it becomes inactive
# otherwise
QApplication.instance().lastWindowClosed.connect(
self.last_window_closed
)
[docs]
def package_library_pop_up(self):
"""Open the package library pop-up"""
self.pop_up_package_library = PackageLibraryDialog(
mia_main_window=self
)
self.pop_up_package_library.setGeometry(300, 200, 800, 600)
self.pop_up_package_library.show()
self.pop_up_package_library.signal_save.connect(
self.pipeline_manager.processLibrary.update_process_library
)
[docs]
def project_properties_pop_up(self):
"""Open the project properties pop-up"""
with self.project.database.data() as database_data:
old_tags = database_data.get_shown_tags()
self.pop_up_settings = PopUpProperties(
self.project, self.data_browser, old_tags
)
self.pop_up_settings.setGeometry(300, 200, 800, 600)
self.pop_up_settings.show()
if self.pop_up_settings.exec():
with self.project.database.data() as database_data:
self.data_browser.table_data.update_visualized_columns(
old_tags, database_data.get_shown_tags()
)
[docs]
def redo(self):
"""Redo the last action made by the user."""
if (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Data Browser"
):
# In Data Browser
self.project.redo(self.data_browser.table_data)
# Action remade in the Database
elif (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Pipeline Manager"
):
# In Pipeline Manager
self.pipeline_manager.redo()
[docs]
def remove_raw_files_useless(self):
"""Remove the useless raw files of the current project.
Close the database connection. The project is not valid any longer
after this call.
"""
folder = self.project.folder
self.project.database.close()
self.project.database = None
# If it's unnamed project, we can remove the whole project
if self.project.isTempProject:
shutil.rmtree(folder)
self.project = None
[docs]
@staticmethod
def run_ipconsole_kernel(mode="qtconsole"):
"""
Starts and initializes an IPython kernel with support for
a Qt-based GUI.
This method is designed to set up and run an IPython kernel
for interactive computing, with the specified mode (defaulting
to `qtconsole`). It handles initialization of the kernel and
associated event loops, ensuring proper integration with Qt-based
applications.
:param mode (str): The mode for running the IPython kernel. Default
is "qtconsole". It determines the GUI integration
mode of the kernel.
:return (IPKernelApp): The instance of the IPython kernel
application.
Notes:
- The method ensures that the kernel is properly initialized if
it hasn't been set up already.
- To support Qt-based GUIs, the Qt event loop is properly
integrated with the IPython kernel.
- Special handling for Tornado versions >= 4.5 ensures
compatibility with its callback mechanism.
"""
logger.info(f"Run_ipconsole_kernel: {mode}")
import IPython # noqa: F401
from IPython.lib import guisupport
from soma.qt_gui.qt_backend import Qt
qtapp = Qt.QApplication.instance()
qtapp._in_event_loop = True
guisupport.in_event_loop = True
from ipykernel.kernelapp import IPKernelApp
app = IPKernelApp.instance()
if not app.initialized() or not app.kernel:
logger.info("Running IP console kernel")
# don't know why this is not set automatically
app.hb_port = 50042
app.initialize(
[
mode,
"--gui=qt", # '--pylab=qt',
"--KernelApp.parent_appname='ipython-{mode}'",
]
)
# in ipython >= 1.2, app.start() blocks until a ctrl-c is issued
# in the terminal. Seems to block in
# tornado.ioloop.PollIOLoop.start()
# So, don't call app.start because it would begin a zmq/tornado
# loop instead we must just initialize its callback.
# if app.poller is not None:
# app.poller.start()
app.kernel.start()
# IP 2 allows just calling the current callbacks.
# For IP 1 it is not sufficient.
import tornado
from zmq.eventloop import ioloop
if tornado.version_info >= (4, 5):
# tornado 5 is using a decque for _callbacks, not a
# list + explicit locking
def my_start_ioloop_callbacks(self):
"""
Executes pending callbacks in the Tornado
IOLoop (Tornado >= 4.5).
This method processes the `_callbacks` deque in the
Tornado IOLoop, executing each callback in the order
they were added. The use of `popleft` ensures efficient
removal of executed callbacks.
Notes:
- Tornado 4.5 and later versions use a `deque`
for `_callbacks`, allowing lock-free access to
pending callbacks.
Raises:
AttributeError: If `_callbacks` is not defined for
the IOLoop instance.
"""
if hasattr(self, "_callbacks"):
ncallbacks = len(self._callbacks)
for i in range(ncallbacks):
self._run_callback(self._callbacks.popleft())
else:
def my_start_ioloop_callbacks(self):
"""
Executes pending callbacks in the Tornado IOLoop
(Tornado < 4.5).
This method processes the `_callbacks` list in the
Tornado IOLoop, executing each callback in the order
they were added. The method ensures thread safety by
using a lock (`_callback_lock`) to protect access to
the `_callbacks` list during execution.
Notes:
- Tornado versions before 4.5 use a list
(`_callbacks`) for pending callbacks, requiring
explicit locking to avoid race conditions.
- After processing all callbacks, the `_callbacks`
list is reset to an empty list.
Raises:
AttributeError: If `_callbacks` or `_callback_lock`
is not defined for the IOLoop
instance.
"""
with self._callback_lock:
callbacks = self._callbacks
self._callbacks = []
for callback in callbacks:
self._run_callback(callback)
my_start_ioloop_callbacks(ioloop.IOLoop.instance())
return app
[docs]
def save(self):
"""Save either the current project or the current pipeline"""
if (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Data Browser"
):
# In Data Browser
self.saveChoice()
elif (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Pipeline Manager"
):
# In Pipeline Manager
self.pipeline_manager.savePipeline()
[docs]
def save_as(self):
"""Save either the current project or the current pipeline under a new
name.
"""
if (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Data Browser"
):
# In Data Browser
self.save_project_as()
elif (
self.tabs.tabText(self.tabs.currentIndex()).replace("&", "", 1)
== "Pipeline Manager"
):
# In Pipeline Manager
self.pipeline_manager.savePipelineAs()
[docs]
def save_project_as(self):
"""Open a pop-up to save the current project as"""
try:
self.exPopup = PopUpSaveProjectAs()
except Exception as e:
logger.warning(f"Save_project_as: {e}")
self.msg = QMessageBox()
self.msg.setIcon(QMessageBox.Critical)
self.msg.setText("Invalid projects folder path")
self.msg.setInformativeText(
"The projects folder path in Mia preferences is invalid!"
)
self.msg.setWindowTitle("Error")
yes_button = self.msg.addButton(
"Open Mia preferences", QMessageBox.YesRole
)
self.msg.addButton(QMessageBox.Ok)
self.msg.exec()
if self.msg.clickedButton() == yes_button:
self.software_preferences_pop_up()
self.msg.close()
else:
if self.test:
self.exPopup.exec = lambda x=0: True
self.exPopup.validate = True
self.exPopup.new_project.text = lambda x=0: "something"
self.exPopup.return_value()
self.exPopup.exec()
if self.exPopup.validate:
old_folder_rel = self.project.folder
old_folder = os.path.abspath(old_folder_rel)
as_folder_rel = self.exPopup.relative_path
as_folder = os.path.abspath(as_folder_rel)
if as_folder_rel == old_folder_rel:
self.project.saveModifications()
return True
database_path = os.path.join(as_folder, "database")
properties_path = os.path.join(as_folder, "properties")
filters_path = os.path.join(as_folder, "filters")
data_path = os.path.join(as_folder, "data")
raw_data_path = os.path.join(data_path, "raw_data")
downloaded_data_path = os.path.join(
data_path, "downloaded_data"
)
# List of projects updated
if not self.test:
self.saved_projects_list = (
self.saved_projects
).addSavedProject(as_folder_rel)
self.update_recent_projects_actions()
if os.path.exists(as_folder_rel):
# Prevent by a careful message
# see PopUpSaveProjectAs/return_value
# in admin mode only
shutil.rmtree(as_folder_rel)
if not os.path.exists(as_folder_rel):
os.makedirs(as_folder_rel)
os.mkdir(data_path)
os.mkdir(raw_data_path)
os.mkdir(downloaded_data_path)
os.mkdir(filters_path)
# Data files copied
if os.path.exists(os.path.join(old_folder_rel, "data")):
for filename in glob.glob(
os.path.join(old_folder, "data", "raw_data", "*")
):
shutil.copy(
filename, os.path.join(data_path, "raw_data")
)
shutil.copytree(
os.path.join(old_folder, "data", "derived_data"),
os.path.join(data_path, "derived_data"),
)
for filename in glob.glob(
os.path.join(
old_folder, "data", "downloaded_data", "*"
)
):
shutil.copy(
filename,
os.path.join(data_path, "downloaded_data"),
)
if os.path.exists(os.path.join(old_folder_rel, "filters")):
for filename in glob.glob(
os.path.join(old_folder, "filters", "*")
):
shutil.copy(filename, os.path.join(filters_path))
# First we register the Database before committing the last
# pending modifications
shutil.copy(
os.path.join(old_folder, "database", "mia.db"),
os.path.join(
old_folder, "database", "mia_before_commit.db"
),
)
# We commit the last pending modifications
self.project.saveModifications()
os.mkdir(properties_path)
shutil.copy(
os.path.join(old_folder, "properties", "properties.yml"),
properties_path,
)
# We copy the Database with all the modifications committed in
# the new project
os.mkdir(database_path)
shutil.copy(
os.path.join(old_folder, "database", "mia.db"),
database_path,
)
reset_old_db = not self.project.isTempProject
# Removing the old project from the list of
# currently opened projects
config = Config()
opened_projects = config.get_opened_projects()
if self.project.folder in opened_projects:
opened_projects.remove(self.project.folder)
config.set_opened_projects(opened_projects)
config.saveConfig()
# We remove the useless files from the old project
self.remove_raw_files_useless()
if reset_old_db:
# We remove the Database with all the modifications saved
# in the old project
os.remove(os.path.join(old_folder, "database", "mia.db"))
# We reput the Database without the last modifications
# in the old project
shutil.copy(
os.path.join(
old_folder, "database", "mia_before_commit.db"
),
os.path.join(old_folder, "database", "mia.db"),
)
os.remove(
os.path.join(
old_folder, "database", "mia_before_commit.db"
)
)
# project updated everywhere
self.project = Project(as_folder_rel, False)
self.project.setName(os.path.basename(as_folder_rel))
self.project.setDate(
datetime.now().strftime("%d/%m/%Y %H:%M:%S")
)
self.project.saveModifications()
self.update_project(as_folder_rel, call_update_table=False)
# project updated everywhere
# If some files have been set in the pipeline editors,
# display a warning message
if (
self.pipeline_manager.pipelineEditorTabs
).has_pipeline_nodes():
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText(
"This action moves the current database. "
"All pipelines will need to be initialized "
"again before they can run."
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
# Update of the history and the brick table in the newly
# created project
self.project.update_db_for_paths()
[docs]
def saveChoice(self):
"""Check if the project needs to be 'saved as' or just 'saved'."""
if self.project.isTempProject:
self.save_project_as()
else:
self.project.saveModifications()
[docs]
def see_all_projects(self):
"""Open a pop-up to show the recent projects."""
# Ui_Dialog() is defined in pop_ups.py
self.exPopup = PopUpSeeAllProjects(self.saved_projects, self)
if self.exPopup.exec():
file_path = self.exPopup.relative_path
if not self.test:
self.saved_projects_list = self.saved_projects.addSavedProject(
file_path
)
self.update_recent_projects_actions()
[docs]
def set_controller_version(self):
"""Reverses the value of the controller_version_changed attribute.
From False to True and vice versa
"""
self.controller_version_changed = not self.controller_version_changed
[docs]
def setup_menu_actions(self, sources_images_dir):
"""
Initialize menu actions with icons and descriptions.
:param sources_images_dir: Directory containing source images
for icons.
"""
self.action_save_project = self.menu_file.addAction("Save project")
self.action_save_project_as = self.menu_file.addAction(
"Save project as"
)
self.action_delete_project = self.menu_file.addAction("Delete project")
self.action_create = QAction("New project", self)
self.action_open = QAction("Open project", self)
self.action_save = QAction("Save", self)
self.action_save_as = QAction("Save as", self)
self.action_delete = QAction("Delete project", self)
self.action_import = QAction(
QIcon(os.path.join(sources_images_dir, "Blue.png")), "Import", self
)
self.action_check_database = QAction("Check the whole database", self)
self.action_see_all_projects = QAction("See all projects", self)
self.action_project_properties = QAction("Project properties", self)
self.action_software_preferences = QAction("MIA preferences", self)
self.action_package_library = QAction("Package library manager", self)
self.action_open_shell = QAction("Open python shell", self)
self.action_exit = QAction(
QIcon(os.path.join(sources_images_dir, "exit.png")), "Exit", self
)
self.action_undo = QAction("Undo", self)
self.action_redo = QAction("Redo", self)
self.action_documentation = QAction("Documentation", self)
self.action_credits = QAction("Credits", self)
self.action_install_processes_folder = QAction("From folder", self)
self.action_install_processes_zip = QAction("From zip file", self)
[docs]
def setup_window_size(self):
"""
Set the window size and maximize if needed.
"""
if self.config.get_mainwindow_maximized():
self.showMaximized()
else:
size = self.config.get_mainwindow_size()
if size:
self.resize(size[0], size[1])
[docs]
def software_preferences_pop_up(self):
"""Open the Mia preferences pop-up."""
self.pop_up_preferences = PopUpPreferences(self)
self.pop_up_preferences.setGeometry(300, 200, 800, 600)
self.pop_up_preferences.show()
self.pop_up_preferences.use_clinical_mode_signal.connect(
self.add_clinical_tags
)
self.pop_up_preferences.not_use_clinical_mode_signal.connect(
self.del_clinical_tags
)
# Modifying the options in the Pipeline Manager (verify if user mode)
self.pop_up_preferences.signal_preferences_change.connect(
self.pipeline_manager.update_user_mode
)
[docs]
def switch_project(self, file_path, name):
"""
Check if it's possible to open the selected project and quit the
current one.
:param file_path: raw file_path
:param name: project name
:return: Boolean
"""
# /!\ file_path and path are the same param
# Switching project only if it's a different one
if file_path == self.project.folder:
return False
# If the file exists
if os.path.exists(os.path.join(file_path)):
# If it is a Mia project
required_paths = [
os.path.join(file_path, "properties", "properties.yml"),
os.path.join(file_path, "database", "mia.db"),
os.path.join(file_path, "data", "raw_data"),
os.path.join(file_path, "data", "derived_data"),
os.path.join(file_path, "data", "downloaded_data"),
os.path.join(file_path, "filters"),
]
if all(os.path.exists(path) for path in required_paths):
# We check if the name of the project directory is the
# same in its properties
with open(
os.path.join(file_path, "properties", "properties.yml"),
"r+",
) as stream:
if version.parse(yaml.__version__) > version.parse("5.1"):
properties = yaml.load(stream, Loader=yaml.FullLoader)
else:
properties = yaml.load(stream)
path, name = os.path.split(file_path)
if properties["name"] != name:
properties["name"] = name
yaml.dump(
properties,
stream,
default_flow_style=False,
allow_unicode=True,
)
# We check for invalid scans in the project
try:
temp_database = Project(file_path, False)
except OSError:
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText("project already opened")
msg.setInformativeText(
f"The project at {file_path} is already opened "
f"in another instance of the software."
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
return False
# We check for valid version of the project
try:
with temp_database.database.data() as database_data:
field_names = database_data.get_field_names(
COLLECTION_CURRENT
)
except ValueError:
field_names = None
if (not field_names) or (TAG_HISTORY not in field_names):
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText(
"The project cannot be read by Mia. Please check "
"if the project version is compatible with "
"the Mia version..."
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
config = Config()
opened_projects = config.get_opened_projects()
if file_path in opened_projects:
opened_projects.remove(file_path)
config.set_opened_projects(opened_projects)
config.saveConfig()
return False
# project removed from the opened projects list
config = Config()
opened_projects = config.get_opened_projects()
if self.project.folder in opened_projects:
opened_projects.remove(self.project.folder)
config.set_opened_projects(opened_projects)
config.saveConfig()
# We remove the useless files from the old project
self.remove_raw_files_useless()
self.project = temp_database # New Database
self.update_project(file_path)
# project updated everywhere
return True
# Not a Mia project
else:
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText("The project selected isn't a valid MIA project")
msg.setInformativeText(
f"The project selected {name} isn't a Mia project"
f".\nPlease select a valid one."
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
return False
# The project doesn't exist anymore
else:
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText("The project selected doesn't exist anymore")
msg.setInformativeText(
f"The project selected {name} doesn't exist anymore."
f"\nPlease select another one."
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QMessageBox.Ok)
msg.buttonClicked.connect(msg.close)
msg.exec()
return False
[docs]
def tab_changed(self):
"""
Update the window when switching between application tab.
Updates the UI state and data when switching between Data Browser,
Data Viewer, and Pipeline Manager tabs. Handles data synchronization,
search state preservation, and unsaved changes warnings.
The method performs the following operations based on the selected
tab:
- Data Browser: Refreshes table data, preserves search state and
visualization settings
- Data Viewer: Loads current viewer and updates document list
- Pipeline Manager: Updates scan lists and handles unsaved
modifications
"""
current_tab = self.tabs.tabText(self.tabs.currentIndex()).replace(
"&", "", 1
)
if current_tab == "Data Browser":
# data_browser refreshed after working in other tab
old_scans = self.data_browser.table_data.scans_to_visualize
with self.project.database.data() as database_data:
documents = database_data.get_document_names(
COLLECTION_CURRENT
)
table_data = self.data_browser.table_data
table_data.add_columns()
table_data.fill_headers()
table_data.add_rows(documents)
table_data.scans_to_visualize = documents
table_data.scans_to_search = documents
table_data.itemChanged.disconnect()
table_data.fill_cells_update_table()
table_data.itemChanged.connect(table_data.change_cell_color)
table_data.update_visualized_rows(old_scans)
# Advanced search + search_bar opened
old_search = self.project.currentFilter.search_bar
self.data_browser.reset_search_bar()
self.data_browser.search_bar.setText(old_search)
if self.project.currentFilter.nots:
self.data_browser.frame_advanced_search.setHidden(False)
self.data_browser.advanced_search.scans_list = (
table_data.scans_to_visualize
)
self.data_browser.advanced_search.show_search()
self.data_browser.advanced_search.apply_filter(
self.project.currentFilter
)
elif current_tab == "Data Viewer":
self.data_viewer.load_viewer(self.data_viewer.current_viewer())
with self.project.database.data() as database_data:
documents = database_data.get_document_names(
COLLECTION_CURRENT
)
self.data_viewer.set_documents(self.project, documents)
elif current_tab == "Pipeline Manager":
if not self.data_browser.data_sent:
with self.project.database.data() as database_data:
scans = database_data.get_document_names(
COLLECTION_CURRENT
)
self.pipeline_manager.scan_list = scans
self.pipeline_manager.nodeController.scan_list = scans
self.pipeline_manager.pipelineEditorTabs.scan_list = scans
self.pipeline_manager.pipelineEditorTabs.update_scans_list()
self.pipeline_manager.update_user_buttons_states()
current_editor = (
self.pipeline_manager.pipelineEditorTabs.get_current_editor()
)
if current_editor.iterated_tag:
self.pipeline_manager.iterationTable.update_iterated_tag(
current_editor.iterated_tag
)
# Pipeline Manager
# The pending modifications must be saved before
# working with pipelines (auto_commit)
if self.project.hasUnsavedModifications():
msg = QMessageBox()
msg.setIcon(QMessageBox.Warning)
msg.setText("Unsaved modifications in the Data Browser !")
msg.setInformativeText(
"There are unsaved modifications in the database, "
"you need to save or remove them before working "
"with pipelines."
)
msg.setWindowTitle("Warning")
save_button = QPushButton("Save")
save_button.clicked.connect(self.project.saveModifications)
unsave_button = QPushButton("Not Save")
unsave_button.clicked.connect(self.project.unsaveModifications)
msg.addButton(save_button, QMessageBox.AcceptRole)
msg.addButton(unsave_button, QMessageBox.AcceptRole)
msg.exec()
[docs]
def undo(self):
"""
Reverts the last action performed by the user, depending on the
active tab.
If the "Data Browser" tab is active, the undo operation is applied
to the project's database. If the "Pipeline Manager" tab is active,
the pipeline manager's undo function is invoked.
"""
tab_name = self.tabs.tabText(self.tabs.currentIndex()).replace(
"&", "", 1
)
if tab_name == "Data Browser":
# In Data Browser
self.project.undo(self.data_browser.table_data)
# Action reverted in the Database
elif tab_name == "Pipeline Manager":
# In Pipeline Manager
self.pipeline_manager.undo()
[docs]
def update_project(self, file_path, call_update_table=True):
"""
Updates the project after a database change.
This method updates the database, the window title, and the recent
and saved projects menus.
:param file_path (str): The file path of the new project.
:param call_update_table (bool): Whether to update the table data.
Defaults to True.
"""
self.data_browser.update_database(self.project)
# Database update data_browser
self.pipeline_manager.update_project(self.project)
if call_update_table:
self.data_browser.table_data.update_table() # Table updated
# Update window title
self.projectName = (
"Unnamed project"
if self.project.isTempProject
else self.project.getName()
)
self.setWindowTitle(f"{self.windowName}{self.projectName}")
# List of project updated
if not self.test and not self.project.isTempProject:
self.saved_projects_list = self.saved_projects.addSavedProject(
file_path
)
self.update_recent_projects_actions()
[docs]
def update_recent_projects_actions(self):
"""
Updates the list of recent projects in the UI.
Hides all recent project actions first, then updates and displays
the most recent ones based on the configured maximum.
"""
max_projects = self.config.get_max_projects()
# Hide all project actions
for action in self.saved_projects_actions[:max_projects]:
action.setVisible(False)
# Update recent projects if available
for i, project in enumerate(self.saved_projects_list[:max_projects]):
self.saved_projects_actions[i].setText(os.path.basename(project))
self.saved_projects_actions[i].setData(project)
self.saved_projects_actions[i].setVisible(True)