diff --git a/src/aiidalab_qe/app/configuration/__init__.py b/src/aiidalab_qe/app/configuration/__init__.py index 6b5414d29..17d932b3e 100644 --- a/src/aiidalab_qe/app/configuration/__init__.py +++ b/src/aiidalab_qe/app/configuration/__init__.py @@ -6,7 +6,6 @@ from __future__ import annotations import ipywidgets as ipw -import traitlets as tl from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.app.utils import get_entry_items @@ -15,7 +14,7 @@ ConfigurationSettingsModel, ConfigurationSettingsPanel, ) -from aiidalab_qe.common.widgets import QeDependentWizardStep +from aiidalab_qe.common.wizard import QeConfirmableDependentWizardStep from .advanced import ( AdvancedConfigurationSettingsModel, @@ -27,14 +26,18 @@ DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore -class ConfigureQeAppWorkChainStep(QeDependentWizardStep[ConfigurationStepModel]): +class ConfigureQeAppWorkChainStep( + QeConfirmableDependentWizardStep[ConfigurationStepModel] +): missing_information_warning = "Missing input structure. Please set it first." def __init__(self, model: ConfigurationStepModel, **kwargs): - super().__init__(model=model, **kwargs) - self._model.observe( - self._on_confirmation_change, - "confirmed", + super().__init__( + model=model, + confirm_kwargs={ + "tooltip": "Confirm the currently selected settings and go to the next step", + }, + **kwargs, ) self._model.observe( self._on_input_structure_change, @@ -69,6 +72,8 @@ def __init__(self, model: ConfigurationStepModel, **kwargs): self._fetch_plugin_calculation_settings() def _render(self): + super()._render() + # RelaxType: degrees of freedom in geometry optimization self.relax_type_help = ipw.HTML() ipw.dlink( @@ -115,22 +120,7 @@ def _render(self): self.sub_steps.set_title(0, "Step 2.1: Select which properties to calculate") self.sub_steps.set_title(1, "Step 2.2: Customize calculation parameters") - self.confirm_button = ipw.Button( - description="Confirm", - tooltip="Confirm the currently selected settings and go to the next step.", - button_style="success", - icon="check-circle", - disabled=True, - layout=ipw.Layout(width="auto"), - ) - ipw.dlink( - (self, "state"), - (self.confirm_button, "disabled"), - lambda state: state != self.State.CONFIGURED, - ) - self.confirm_button.on_click(self.confirm) - - self.children = [ + self.content.children = [ InAppGuide(identifier="configuration-step"), ipw.HTML("""
@@ -140,28 +130,22 @@ def _render(self): self.relax_type_help, self.relax_type, self.sub_steps, - self.confirm_button, + ] + + self.children = [ + self.content, + self.confirm_box, ] def _post_render(self): self._update_tabs() - def is_saved(self): - return self._model.confirmed - - def confirm(self, _=None): - self._model.confirm() - def reset(self): self._model.reset() if self.rendered: self.sub_steps.selected_index = None self.tabs.selected_index = 0 - @tl.observe("previous_step_state") - def _on_previous_step_state_change(self, _): - self._update_state() - def _on_tab_change(self, change): if (tab_index := change["new"]) is None: return @@ -173,9 +157,6 @@ def _on_input_structure_change(self, _): self._model.update() self.reset() - def _on_confirmation_change(self, _): - self._update_state() - def _update_tabs(self): children = [] titles = [] diff --git a/src/aiidalab_qe/app/configuration/model.py b/src/aiidalab_qe/app/configuration/model.py index ceb19dc07..b8b664c88 100644 --- a/src/aiidalab_qe/app/configuration/model.py +++ b/src/aiidalab_qe/app/configuration/model.py @@ -6,12 +6,11 @@ from aiida_quantumespresso.common.types import RelaxType from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.common.mixins import ( - Confirmable, HasInputStructure, HasModels, ) from aiidalab_qe.common.panel import ConfigurationSettingsModel -from aiidalab_qe.common.widgets import QeWizardStepModel +from aiidalab_qe.common.wizard import QeConfirmableWizardStepModel DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore @@ -19,10 +18,9 @@ class ConfigurationStepModel( - QeWizardStepModel, + QeConfirmableWizardStepModel, HasModels[ConfigurationSettingsModel], HasInputStructure, - Confirmable, ): identifier = "configuration" diff --git a/src/aiidalab_qe/app/main.py b/src/aiidalab_qe/app/main.py index e482b73c1..a36c54c3a 100644 --- a/src/aiidalab_qe/app/main.py +++ b/src/aiidalab_qe/app/main.py @@ -23,14 +23,14 @@ class QeApp: def __init__( self, process=None, - qe_auto_setup=True, + auto_setup=True, bug_report_url=DEFAULT_BUG_REPORT_URL, show_log=False, ): """Initialize the AiiDAlab QE application with the necessary setup.""" self.process = process - self.qe_auto_setup = qe_auto_setup + self.auto_setup = auto_setup self.log_widget = None self._load_styles() @@ -83,7 +83,7 @@ def _load_styles(self): def load(self): """Initialize the WizardApp and integrate the app into the main view.""" self.app = WizardApp( - qe_auto_setup=self.qe_auto_setup, + auto_setup=self.auto_setup, log_widget=self.log_widget, ) self.view.main.children = [self.app] diff --git a/src/aiidalab_qe/app/result/__init__.py b/src/aiidalab_qe/app/result/__init__.py index 1f474136e..a3fbe841c 100644 --- a/src/aiidalab_qe/app/result/__init__.py +++ b/src/aiidalab_qe/app/result/__init__.py @@ -3,7 +3,8 @@ from aiida.engine import ProcessState from aiidalab_qe.common.infobox import InAppGuide -from aiidalab_qe.common.widgets import LoadingWidget, QeDependentWizardStep +from aiidalab_qe.common.widgets import LoadingWidget +from aiidalab_qe.common.wizard import QeDependentWizardStep from aiidalab_widgets_base import ProcessMonitor, WizardAppWidgetStep from .components import ResultsComponent diff --git a/src/aiidalab_qe/app/result/model.py b/src/aiidalab_qe/app/result/model.py index d8cdb4f87..c6b6c5564 100644 --- a/src/aiidalab_qe/app/result/model.py +++ b/src/aiidalab_qe/app/result/model.py @@ -7,7 +7,7 @@ from aiida import orm from aiida.engine.processes import control from aiidalab_qe.common.mixins import HasModels, HasProcess -from aiidalab_qe.common.widgets import QeWizardStepModel +from aiidalab_qe.common.wizard import QeWizardStepModel from .components import ResultsComponentModel diff --git a/src/aiidalab_qe/app/static/styles/custom.css b/src/aiidalab_qe/app/static/styles/custom.css index 914686bbd..d47bf643a 100644 --- a/src/aiidalab_qe/app/static/styles/custom.css +++ b/src/aiidalab_qe/app/static/styles/custom.css @@ -118,3 +118,8 @@ footer { padding: 0 5px; margin: -1px; } + +.blocker-messages .alert { + margin-bottom: 0; + padding: 10px; +} diff --git a/src/aiidalab_qe/app/structure/__init__.py b/src/aiidalab_qe/app/structure/__init__.py index 8f2b85af3..31ee86477 100644 --- a/src/aiidalab_qe/app/structure/__init__.py +++ b/src/aiidalab_qe/app/structure/__init__.py @@ -17,7 +17,8 @@ ShakeNBreakEditor, ) from aiidalab_qe.common.infobox import InAppGuide -from aiidalab_qe.common.widgets import CategorizedStructureExamplesWidget, QeWizardStep +from aiidalab_qe.common.widgets import CategorizedStructureExamplesWidget +from aiidalab_qe.common.wizard import QeConfirmableWizardStep from aiidalab_widgets_base import ( BasicCellEditor, BasicStructureEditor, @@ -25,7 +26,7 @@ StructureUploadWidget, ) -# The Examples list of (name, file) tuple curretly passed to +# The Examples list of (name, file) tuple currently passed to # StructureExamplesWidget. file_path = pathlib.Path(__file__).parent Examples = [ @@ -39,25 +40,38 @@ ] -class StructureSelectionStep(QeWizardStep[StructureStepModel]): +class StructureSelectionStep(QeConfirmableWizardStep[StructureStepModel]): """Integrated widget for the selection and edition of structure. The widget includes a structure manager that allows to select a structure from different sources. It also includes the structure editor. Both the structure importers and the structure editors can be extended by plugins. """ - def __init__(self, model: StructureStepModel, **kwargs): - super().__init__(model=model, **kwargs) + def __init__(self, model: StructureStepModel, auto_setup=True, **kwargs): + super().__init__( + model=model, + confirm_kwargs={ + "tooltip": "Confirm the currently selected structure and go to the next step", + }, + **kwargs, + ) + self._model.observe( + self._on_installation_change, + ["installing_sssp", "sssp_installed"], + ) self._model.observe( - self._on_confirmation_change, - "confirmed", + self._on_sssp_installed, + "sssp_installed", ) self._model.observe( self._on_input_structure_change, "input_structure", ) + self._install_sssp(auto_setup) def _render(self): + super()._render() + examples_by_category = {"Simple crystals": Examples} plugin_structure_examples = { item["title"]: item["structures"] @@ -65,7 +79,7 @@ def _render(self): "aiidalab_qe.properties", "structure_examples" ).values() } - examples_by_category.update(plugin_structure_examples) + examples_by_category |= plugin_structure_examples importers = [ StructureUploadWidget(title="Upload file"), @@ -129,27 +143,7 @@ def _render(self): (self.structure_name_text, "value"), ) - self.confirm_button = ipw.Button( - description="Confirm", - tooltip="Confirm the currently selected structure and go to the next step.", - button_style="success", - icon="check-circle", - layout=ipw.Layout(width="auto"), - ) - ipw.dlink( - (self, "state"), - (self.confirm_button, "disabled"), - lambda state: state != self.State.CONFIGURED, - ) - self.confirm_button.on_click(self.confirm) - - self.message_area = ipw.HTML() - ipw.dlink( - (self._model, "message_area"), - (self.message_area, "value"), - ) - - self.children = [ + self.content.children = [ InAppGuide(identifier="structure-step"), ipw.HTML("""

@@ -161,20 +155,23 @@ def _render(self): """), self.manager, self.structure_name_text, - self.message_area, - self.confirm_button, ] - # after rendering the widget, nglview needs to be resized + + self.confirm_box.children += (self.sssp_installation,) + + self.children = [ + self.content, + self.confirm_box, + ] + + def _post_render(self): + # After rendering the widget, nglview needs to be resized # to properly display the structure self.manager.viewer._viewer.handle_resize() - def is_saved(self): - return self._model.confirmed - def confirm(self, _=None): self.manager.store_structure() - self._model.message_area = "" - self._model.confirm() + super().confirm() def can_reset(self): return self._model.confirmed @@ -182,12 +179,39 @@ def can_reset(self): def reset(self): self._model.reset() + def _on_installation_change(self, _): + self._model.update_blockers() + + def _on_sssp_installed(self, _): + self._toggle_sssp_installation_widget() + def _on_input_structure_change(self, _): self._model.update_widget_text() self._update_state() - def _on_confirmation_change(self, _): - self._update_state() + def _install_sssp(self, auto_setup): + from aiidalab_qe.common.setup_pseudos import PseudosInstallWidget + + self.sssp_installation = PseudosInstallWidget(auto_start=False) + ipw.dlink( + (self.sssp_installation, "busy"), + (self._model, "installing_sssp"), + ) + ipw.dlink( + (self.sssp_installation, "installed"), + (self._model, "installing_sssp"), + lambda installed: not installed, + ) + ipw.dlink( + (self.sssp_installation, "installed"), + (self._model, "sssp_installed"), + ) + if auto_setup: + self.sssp_installation.refresh() + + def _toggle_sssp_installation_widget(self): + sssp_installation_display = "none" if self._model.sssp_installed else "block" + self.sssp_installation.layout.display = sssp_installation_display def _update_state(self): if self._model.confirmed: diff --git a/src/aiidalab_qe/app/structure/model.py b/src/aiidalab_qe/app/structure/model.py index 8cbaf4c2c..25b264d19 100644 --- a/src/aiidalab_qe/app/structure/model.py +++ b/src/aiidalab_qe/app/structure/model.py @@ -1,24 +1,31 @@ import traitlets as tl -from aiidalab_qe.common.mixins import Confirmable, HasInputStructure -from aiidalab_qe.common.widgets import QeWizardStepModel +from aiidalab_qe.common.mixins import HasInputStructure +from aiidalab_qe.common.wizard import QeConfirmableWizardStepModel class StructureStepModel( - QeWizardStepModel, + QeConfirmableWizardStepModel, HasInputStructure, - Confirmable, ): identifier = "structure" structure_name = tl.Unicode("") manager_output = tl.Unicode("") - message_area = tl.Unicode("") + + installing_sssp = tl.Bool(False) + sssp_installed = tl.Bool(allow_none=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.confirmation_exceptions += [ + "installing_sssp", + "sssp_installed", + ] def update_widget_text(self): if not self.has_structure: self.structure_name = "" - self.message_area = "" else: self.manager_output = "" self.structure_name = str(self.input_structure.get_formula()) @@ -27,4 +34,7 @@ def reset(self): self.input_structure = None self.structure_name = "" self.manager_output = "" - self.message_area = "" + + def _check_blockers(self): + if not self.sssp_installed: + yield "The SSSP library is not installed" diff --git a/src/aiidalab_qe/app/submission/__init__.py b/src/aiidalab_qe/app/submission/__init__.py index c3777adfd..d2ad699f2 100644 --- a/src/aiidalab_qe/app/submission/__init__.py +++ b/src/aiidalab_qe/app/submission/__init__.py @@ -6,7 +6,6 @@ from __future__ import annotations import ipywidgets as ipw -import traitlets as tl from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.app.utils import get_entry_items @@ -18,8 +17,8 @@ ResourceSettingsPanel, ) from aiidalab_qe.common.setup_codes import QESetupWidget -from aiidalab_qe.common.setup_pseudos import PseudosInstallWidget -from aiidalab_qe.common.widgets import LinkButton, QeDependentWizardStep +from aiidalab_qe.common.widgets import LinkButton +from aiidalab_qe.common.wizard import QeConfirmableDependentWizardStep from .global_settings import GlobalResourceSettingsModel, GlobalResourceSettingsPanel from .model import SubmissionStepModel @@ -27,33 +26,18 @@ DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore -class SubmitQeAppWorkChainStep(QeDependentWizardStep[SubmissionStepModel]): +class SubmitQeAppWorkChainStep(QeConfirmableDependentWizardStep[SubmissionStepModel]): missing_information_warning = "Missing input structure and/or configuration parameters. Please set them first." - def __init__(self, model: SubmissionStepModel, qe_auto_setup=True, **kwargs): - super().__init__(model=model, **kwargs) - self._model.observe( - self._on_submission, - "confirmed", - ) - self._model.observe( - self._on_input_parameters_change, - "input_parameters", - ) - self._model.observe( - self._on_submission_blockers_change, - [ - "internal_submission_blockers", - "external_submission_blockers", - ], - ) - self._model.observe( - self._on_installation_change, - ["installing_sssp", "sssp_installed"], - ) - self._model.observe( - self._on_sssp_installed, - "sssp_installed", + def __init__(self, model: SubmissionStepModel, auto_setup=True, **kwargs): + super().__init__( + model=model, + confirm_kwargs={ + "description": "Submit", + "tooltip": "Submit the calculation with the selected parameters.", + "icon": "play", + }, + **kwargs, ) self._model.observe( self._on_installation_change, @@ -63,6 +47,10 @@ def __init__(self, model: SubmissionStepModel, qe_auto_setup=True, **kwargs): self._on_qe_installed, "qe_installed", ) + self._model.observe( + self._on_input_parameters_change, + "input_parameters", + ) global_resources_model = GlobalResourceSettingsModel() self.global_resources = GlobalResourceSettingsPanel( @@ -74,12 +62,12 @@ def __init__(self, model: SubmissionStepModel, qe_auto_setup=True, **kwargs): (global_resources_model, "plugin_overrides"), ) global_resources_model.observe( - self._on_plugin_submission_blockers_change, - ["submission_blockers"], + self._on_plugin_blockers_change, + "blockers", ) global_resources_model.observe( - self._on_plugin_submission_warning_messages_change, - ["submission_warning_messages"], + self._on_plugin_warning_messages_change, + ["warning_messages"], ) self.settings = { @@ -87,10 +75,11 @@ def __init__(self, model: SubmissionStepModel, qe_auto_setup=True, **kwargs): } self._fetch_plugin_resource_settings() - self._install_sssp(qe_auto_setup) - self._set_up_qe(qe_auto_setup) + self._set_up_qe(auto_setup) def _render(self): + super()._render() + self.process_label = ipw.Text( description="Label:", layout=ipw.Layout(width="auto", indent="0px"), @@ -108,31 +97,10 @@ def _render(self): (self.process_description, "value"), ) - self.submit_button = ipw.Button( - description="Submit", - tooltip="Submit the calculation with the selected parameters.", - icon="play", - button_style="success", - layout=ipw.Layout(width="auto", flex="1 1 auto"), - disabled=True, - ) - ipw.dlink( - (self, "state"), - (self.submit_button, "disabled"), - lambda state: state != self.State.CONFIGURED, - ) - self.submit_button.on_click(self.submit) - - self.submission_blocker_messages = ipw.HTML() + self.warning_messages = ipw.HTML() ipw.dlink( - (self._model, "submission_blocker_messages"), - (self.submission_blocker_messages, "value"), - ) - - self.submission_warning_messages = ipw.HTML() - ipw.dlink( - (self._model, "submission_warning_messages"), - (self.submission_warning_messages, "value"), + (self._model, "warning_messages"), + (self.warning_messages, "value"), ) self.setup_new_codes_button = LinkButton( @@ -158,7 +126,7 @@ def _render(self): "selected_index", ) - self.children = [ + self.content.children = [ InAppGuide(identifier="submission-step"), ipw.HTML("""

@@ -179,10 +147,6 @@ def _render(self): layout=ipw.Layout(grid_gap="5px"), ), self.tabs, - self.sssp_installation, - self.qe_setup, - self.submission_blocker_messages, - self.submission_warning_messages, ipw.HTML("""
Label your job and provide a brief description. These details @@ -193,22 +157,21 @@ def _render(self): """), self.process_label, self.process_description, - self.submit_button, + ] + + self.confirm_box.children += (self.qe_setup,) + + self.children = [ + self.content, + self.confirm_box, ] def _post_render(self): self._update_tabs() - def submit(self, _=None): - self._model.confirm() - def reset(self): self._model.reset() - @tl.observe("previous_step_state") - def _on_previous_step_state_change(self, _): - self._update_state() - def _on_tab_change(self, change): if (tab_index := change["new"]) is None: return @@ -219,55 +182,28 @@ def _on_input_parameters_change(self, _): self._model.update_process_label() self._model.update_plugin_inclusion() self._model.update_plugin_overrides() - self._model.update_submission_blockers() + self._model.update_blockers() self._update_tabs() def _on_plugin_overrides_change(self, _): self._model.update_plugin_overrides() - def _on_plugin_submission_blockers_change(self, _): - self._model.update_submission_blockers() - - def _on_plugin_submission_warning_messages_change(self, _): - self._model.update_submission_warnings() + def _on_plugin_blockers_change(self, _): + self._model.update_blockers() - def _on_submission_blockers_change(self, _): - self._model.update_submission_blocker_message() - self._update_state() + def _on_plugin_warning_messages_change(self, _): + self._model.update_warnings() def _on_installation_change(self, _): - self._model.update_submission_blockers() + self._model.update_blockers() def _on_qe_installed(self, _): self._toggle_qe_installation_widget() if self._model.qe_installed: self._model.update() + self._refresh_resources() - def _on_sssp_installed(self, _): - self._toggle_sssp_installation_widget() - - def _on_submission(self, _): - self._update_state() - - def _install_sssp(self, qe_auto_setup): - self.sssp_installation = PseudosInstallWidget(auto_start=False) - ipw.dlink( - (self.sssp_installation, "busy"), - (self._model, "installing_sssp"), - ) - ipw.dlink( - (self.sssp_installation, "installed"), - (self._model, "installing_sssp"), - lambda installed: not installed, - ) - ipw.dlink( - (self.sssp_installation, "installed"), - (self._model, "sssp_installed"), - ) - if qe_auto_setup: - self.sssp_installation.refresh() - - def _set_up_qe(self, qe_auto_setup): + def _set_up_qe(self, auto_setup): self.qe_setup = QESetupWidget(auto_start=False) ipw.dlink( (self.qe_setup, "busy"), @@ -282,13 +218,9 @@ def _set_up_qe(self, qe_auto_setup): (self.qe_setup, "installed"), (self._model, "qe_installed"), ) - if qe_auto_setup: + if auto_setup: self.qe_setup.refresh() - def _toggle_sssp_installation_widget(self): - sssp_installation_display = "none" if self._model.sssp_installed else "block" - self.sssp_installation.layout.display = sssp_installation_display - def _toggle_qe_installation_widget(self): qe_installation_display = "none" if self._model.qe_installed else "block" self.qe_setup.layout.display = qe_installation_display @@ -342,12 +274,12 @@ def _fetch_plugin_resource_settings(self): "override", ) model.observe( - self._on_plugin_submission_blockers_change, - ["submission_blockers"], + self._on_plugin_blockers_change, + "blockers", ) model.observe( - self._on_plugin_submission_warning_messages_change, - ["submission_warning_messages"], + self._on_plugin_warning_messages_change, + "warning_messages", ) self._model.add_model(identifier, model) diff --git a/src/aiidalab_qe/app/submission/global_settings/model.py b/src/aiidalab_qe/app/submission/global_settings/model.py index afd43eeab..fd116314d 100644 --- a/src/aiidalab_qe/app/submission/global_settings/model.py +++ b/src/aiidalab_qe/app/submission/global_settings/model.py @@ -141,7 +141,7 @@ def check_resources(self): # List of possible suggestions for warnings: suggestions = { - "more_resources": f"
  • Increase the resources (total number of CPUs should be equal or more than {min(100,estimated_CPUs)}, if possible)
  • ", + "more_resources": f"
  • Increase the resources (total number of CPUs should be equal or more than {min(100, estimated_CPUs)}, if possible)
  • ", "change_configuration": "
  • Review the configuration (e.g. choosing fast protocol - this will affect precision)
  • ", "go_remote": "
  • Select a code that runs on a larger machine
  • ", "avoid_overloading": "
  • Reduce the number of CPUs to avoid the overloading of the local machine
  • ", @@ -194,7 +194,7 @@ def check_resources(self): + "" ) - self.submission_warning_messages = ( + self.warning_messages = ( "" if (on_localhost and num_cpus / machine_cpus) <= 0.8 and (not large_system or estimated_CPUs <= num_cpus) @@ -207,7 +207,7 @@ def check_resources(self): def _get_properties(self) -> list[str]: return self.input_parameters.get("workchain", {}).get("properties", []) - def _check_submission_blockers(self): + def _check_blockers(self): # No pw code selected pw_code_model = self.get_model("quantumespresso__pw") if pw_code_model and not pw_code_model.selected: diff --git a/src/aiidalab_qe/app/submission/global_settings/setting.py b/src/aiidalab_qe/app/submission/global_settings/setting.py index d7f330d2b..412731c53 100644 --- a/src/aiidalab_qe/app/submission/global_settings/setting.py +++ b/src/aiidalab_qe/app/submission/global_settings/setting.py @@ -91,7 +91,7 @@ def _on_code_activation_change(self, change): self._toggle_code(change["owner"]) def _on_code_selection_change(self, _): - self._model.update_submission_blockers() + self._model.update_blockers() def _on_pw_code_resource_change(self, _): self._model.check_resources() diff --git a/src/aiidalab_qe/app/submission/model.py b/src/aiidalab_qe/app/submission/model.py index ff93bb032..c30072537 100644 --- a/src/aiidalab_qe/app/submission/model.py +++ b/src/aiidalab_qe/app/submission/model.py @@ -10,19 +10,18 @@ from aiida.engine import ProcessBuilderNamespace, submit from aiida.orm.utils.serialize import serialize from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS -from aiidalab_qe.common.mixins import Confirmable, HasInputStructure, HasModels +from aiidalab_qe.common.mixins import HasInputStructure, HasModels from aiidalab_qe.common.panel import PluginResourceSettingsModel, ResourceSettingsModel -from aiidalab_qe.common.widgets import QeWizardStepModel +from aiidalab_qe.common.wizard import QeConfirmableWizardStepModel from aiidalab_qe.workflows import QeAppWorkChain DEFAULT: dict = DEFAULT_PARAMETERS # type: ignore class SubmissionStepModel( - QeWizardStepModel, + QeConfirmableWizardStepModel, HasModels[ResourceSettingsModel], HasInputStructure, - Confirmable, ): identifier = "submission" @@ -32,32 +31,20 @@ class SubmissionStepModel( process_label = tl.Unicode("") process_description = tl.Unicode("") - internal_submission_blockers = tl.List(tl.Unicode()) - external_submission_blockers = tl.List(tl.Unicode()) - submission_blocker_messages = tl.Unicode("") - submission_warning_messages = tl.Unicode("") + warning_messages = tl.Unicode("") installing_qe = tl.Bool(False) - installing_sssp = tl.Bool(False) qe_installed = tl.Bool(allow_none=True) - sssp_installed = tl.Bool(allow_none=True) plugin_overrides = tl.List(tl.Unicode()) - confirmation_exceptions = [ - "confirmed", - "internal_submission_blockers", - "external_submission_blockers", - "submission_blocker_messages", - "submission_warning_messages", - "installing_qe", - "installing_sssp", - "qe_installed", - "sssp_installed", - ] - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.confirmation_exceptions += [ + "warning_messages", + "installing_qe", + "qe_installed", + ] self._default_models = { "global", @@ -72,15 +59,6 @@ def __init__(self, *args, **kwargs):
    """ - @property - def is_blocked(self): - return any( - [ - *self.internal_submission_blockers, - *self.external_submission_blockers, - ] - ) - def confirm(self): super().confirm() if not self.process_node: @@ -152,32 +130,11 @@ def update_plugin_overrides(self): and model.override ] - def update_submission_blockers(self): - submission_blockers = list(self._check_submission_blockers()) - for _, model in self.get_models(): - submission_blockers += model.submission_blockers - self.internal_submission_blockers = submission_blockers - - def update_submission_warnings(self): - submission_warning_messages = self._check_submission_warnings() + def update_warnings(self): + warning_messages = self._check_warnings() for _, model in self.get_models(): - submission_warning_messages += model.submission_warning_messages - self.submission_warning_messages = submission_warning_messages - - def update_submission_blocker_message(self): - blockers = self.internal_submission_blockers + self.external_submission_blockers - if any(blockers): - formatted = "\n".join(f"
  • {item}
  • " for item in blockers) - self.submission_blocker_messages = f""" -
    - The submission is blocked due to the following reason(s): -
      - {formatted} -
    -
    - """ - else: - self.submission_blocker_messages = "" + warning_messages += model.warning_messages + self.warning_messages = warning_messages def get_model_state(self) -> dict[str, dict[str, dict]]: parameters: dict = deepcopy(self.input_parameters) # type: ignore @@ -295,13 +252,13 @@ def _create_builder(self, parameters) -> ProcessBuilderNamespace: return builder - def _check_submission_blockers(self): - if self.installing_qe or self.installing_sssp: - yield "Background setup processes must finish." + def _check_blockers(self): + if self.installing_qe: + yield "Installing Quantum ESPRESSO codes..." - if not self.sssp_installed: - yield "The SSSP library is not installed." + if not self.qe_installed: + yield "Quantum ESPRESSO is not yet installed" - def _check_submission_warnings(self): + def _check_warnings(self): """Check for any warnings that should be displayed to the user.""" return "" diff --git a/src/aiidalab_qe/app/wizard_app.py b/src/aiidalab_qe/app/wizard_app.py index 93944ec18..d75a2fe3e 100644 --- a/src/aiidalab_qe/app/wizard_app.py +++ b/src/aiidalab_qe/app/wizard_app.py @@ -13,7 +13,8 @@ from aiidalab_qe.app.submission import SubmitQeAppWorkChainStep from aiidalab_qe.app.submission.model import SubmissionStepModel from aiidalab_qe.common.infobox import InAppGuide -from aiidalab_qe.common.widgets import LoadingWidget, QeWizardStep +from aiidalab_qe.common.widgets import LoadingWidget +from aiidalab_qe.common.wizard import QeWizardStep from aiidalab_widgets_base import WizardAppWidget @@ -23,7 +24,7 @@ class WizardApp(ipw.VBox): # The PK or UUID of the work chain node. process = tl.Union([tl.Unicode(), tl.Int()], allow_none=True) - def __init__(self, qe_auto_setup=True, **kwargs): + def __init__(self, auto_setup=True, **kwargs): # Initialize the models self.structure_model = StructureStepModel() self.configure_model = ConfigurationStepModel() @@ -36,6 +37,7 @@ def __init__(self, qe_auto_setup=True, **kwargs): self.structure_step = StructureSelectionStep( model=self.structure_model, auto_advance=True, + auto_setup=auto_setup, ) self.configure_step = ConfigureQeAppWorkChainStep( model=self.configure_model, @@ -44,7 +46,7 @@ def __init__(self, qe_auto_setup=True, **kwargs): self.submit_step = SubmitQeAppWorkChainStep( model=self.submit_model, auto_advance=True, - qe_auto_setup=qe_auto_setup, + auto_setup=auto_setup, ) self.results_step = ViewQeAppWorkChainStatusAndResultsStep( model=self.results_model, @@ -113,8 +115,6 @@ def __init__(self, qe_auto_setup=True, **kwargs): self.structure_step.state = QeWizardStep.State.READY - self._update_blockers() - @property def steps(self): return self._wizard_app_widget.steps @@ -132,11 +132,9 @@ def _on_step_change(self, change): def _on_structure_confirmation_change(self, _): self._update_configuration_step() - self._update_blockers() def _on_configuration_confirmation_change(self, _): self._update_submission_step() - self._update_blockers() def _on_submission(self, _): self._update_results_step() @@ -175,13 +173,6 @@ def _lock_app(self): ): model.unobserve_all("confirmed") - def _update_blockers(self): - self.submit_model.external_submission_blockers = [ - f"Unsaved changes in the {title} step. Please confirm the changes before submitting." - for title, step in self.steps[:2] - if not step.is_saved() - ] - def _update_from_process(self, pk): if pk is None: self._wizard_app_widget.reset() diff --git a/src/aiidalab_qe/common/mixins.py b/src/aiidalab_qe/common/mixins.py index 220f6a8bb..782d45046 100644 --- a/src/aiidalab_qe/common/mixins.py +++ b/src/aiidalab_qe/common/mixins.py @@ -125,3 +125,37 @@ def _on_any_change(self, change): def _unconfirm(self): self.confirmed = False + + +class HasBlockers(tl.HasTraits): + blockers = tl.List(tl.Unicode()) + blocker_messages = tl.Unicode("") + + @property + def is_blocked(self): + return any(self.blockers) + + def update_blockers(self): + blockers = list(self._check_blockers()) + if isinstance(self, HasModels): + for _, model in self.get_models(): + if isinstance(model, HasBlockers): + blockers += model.blockers + self.blockers = blockers + + def update_blocker_messages(self): + if self.is_blocked: + formatted = "\n".join(f"
  • {item}
  • " for item in self.blockers) + self.blocker_messages = f""" +
    + The step is blocked due to the following reason(s): +
      + {formatted} +
    +
    + """ + else: + self.blocker_messages = "" + + def _check_blockers(self): + raise NotImplementedError diff --git a/src/aiidalab_qe/common/panel.py b/src/aiidalab_qe/common/panel.py index 5a42fd075..543094dc2 100644 --- a/src/aiidalab_qe/common/panel.py +++ b/src/aiidalab_qe/common/panel.py @@ -18,7 +18,7 @@ from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.common.code.model import CodeModel from aiidalab_qe.common.infobox import InAppGuide -from aiidalab_qe.common.mixins import Confirmable, HasModels, HasProcess +from aiidalab_qe.common.mixins import Confirmable, HasBlockers, HasModels, HasProcess from aiidalab_qe.common.mvc import Model from aiidalab_qe.common.widgets import ( LoadingWidget, @@ -88,7 +88,7 @@ def __init__(self, **kwargs): ) -class SettingsModel(PanelModel): +class SettingsModel(PanelModel, HasBlockers): """Base model for settings models.""" dependencies: list[str] = [] @@ -206,8 +206,7 @@ class ResourceSettingsModel(SettingsModel, HasModels[CodeModel]): value_trait=tl.Dict(), ) - submission_blockers = tl.List(tl.Unicode()) - submission_warning_messages = tl.Unicode("") + warning_messages = tl.Unicode("") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -223,9 +222,6 @@ def refresh_codes(self): for _, code_model in self.get_models(): code_model.update(self.DEFAULT_USER_EMAIL, refresh=True) - def update_submission_blockers(self): - self.submission_blockers = list(self._check_submission_blockers()) - def get_model_state(self): return { "codes": { @@ -250,7 +246,7 @@ def set_selected_codes(self, code_data=DEFAULT["codes"]): if identifier in code_data: code_model.set_model_state(code_data[identifier]) - def _check_submission_blockers(self): + def _check_blockers(self): return [] @@ -267,6 +263,10 @@ def __init__(self, model, **kwargs): def _on_code_resource_change(self, _): pass + def _on_code_options_change(self, change: dict): + widget: ipw.Dropdown = change["owner"] + widget.disabled = not widget.options + def _toggle_code(self, code_model: CodeModel): if not self.rendered: return @@ -348,6 +348,10 @@ def _render_code_widget( "max_wallclock_seconds", ], ) + code_widget.code_selection.code_select_dropdown.observe( + self._on_code_options_change, + "options", + ) code_widgets = self.code_widgets_container.children[:-1] # type: ignore self.code_widgets_container.children = [*code_widgets, code_widget] code_model.is_rendered = True diff --git a/src/aiidalab_qe/common/widgets.py b/src/aiidalab_qe/common/widgets.py index 5c492c6aa..a50746308 100644 --- a/src/aiidalab_qe/common/widgets.py +++ b/src/aiidalab_qe/common/widgets.py @@ -5,7 +5,6 @@ import base64 import hashlib -import os import subprocess import typing as t from copy import deepcopy @@ -26,11 +25,9 @@ from aiida.orm import CalcJobNode, load_code, load_node from aiida.orm import Data as orm_Data -from aiidalab_qe.common.mvc import Model from aiidalab_widgets_base import ( ComputationalResourcesWidget, StructureExamplesWidget, - WizardAppWidgetStep, ) from aiidalab_widgets_base.utils import ( StatusHTML, @@ -1189,80 +1186,6 @@ def _on_disabled(self, change): self.remove_class("disabled") -class QeWizardStepModel(Model): - identifier = "QE wizard" - - -QWSM = t.TypeVar("QWSM", bound=QeWizardStepModel) - - -class QeWizardStep(ipw.VBox, WizardAppWidgetStep, t.Generic[QWSM]): - def __init__(self, model: QWSM, **kwargs): - self.loading_message = LoadingWidget(f"Loading {model.identifier} step") - super().__init__(children=[self.loading_message], **kwargs) - self._model = model - self.rendered = False - self._background_class = "" - - def render(self): - if self.rendered: - return - self._render() - self.rendered = True - self._post_render() - - @traitlets.observe("state") - def _on_state_change(self, change): - self._update_background_color(change["new"]) - - def _render(self): - raise NotImplementedError() - - def _post_render(self): - pass - - def _update_background_color(self, state: WizardAppWidgetStep.State): - self.remove_class(self._background_class) - self._background_class = f"qe-app-step-{state.name.lower()}" - self.add_class(self._background_class) - - -class QeDependentWizardStep(QeWizardStep[QWSM]): - missing_information_warning = "Missing information" - - previous_step_state = traitlets.UseEnum(WizardAppWidgetStep.State) - - def __init__(self, model: QWSM, **kwargs): - super().__init__(model, **kwargs) - self.previous_children = list(self.children) - self.warning_message = ipw.HTML( - f""" -
    - Warning: {self.missing_information_warning} -
    - """ - ) - - def render(self): - if "PYTEST_CURRENT_TEST" in os.environ: - super().render() - return - if self.previous_step_state is WizardAppWidgetStep.State.SUCCESS: - self._hide_missing_information_warning() - if not self.rendered: - super().render() - self.previous_children = list(self.children) - else: - self._show_missing_information_warning() - - def _show_missing_information_warning(self): - self.children = [self.warning_message] - self.rendered = False - - def _hide_missing_information_warning(self): - self.children = self.previous_children - - class TableWidget(anywidget.AnyWidget): _esm = """ function render({ model, el }) { diff --git a/src/aiidalab_qe/common/wizard.py b/src/aiidalab_qe/common/wizard.py new file mode 100644 index 000000000..3e9bee8de --- /dev/null +++ b/src/aiidalab_qe/common/wizard.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import os +import typing as t + +import ipywidgets as ipw +import traitlets as tl + +from aiidalab_qe.common.mixins import Confirmable, HasBlockers, HasModels +from aiidalab_qe.common.mvc import Model +from aiidalab_widgets_base import WizardAppWidgetStep + +from .widgets import LoadingWidget + + +class QeWizardStepModel(Model): + identifier = "QE wizard" + + +WSM = t.TypeVar("WSM", bound=QeWizardStepModel) + + +class QeWizardStep(ipw.VBox, WizardAppWidgetStep, t.Generic[WSM]): + def __init__(self, model: WSM, **kwargs): + self.loading_message = LoadingWidget(f"Loading {model.identifier} step") + super().__init__(children=[self.loading_message], **kwargs) + self._model = model + self.rendered = False + self._background_class = "" + + def render(self): + if self.rendered: + return + self._render() + self.rendered = True + self._post_render() + + @tl.observe("state") + def _on_state_change(self, change): + self._update_background_color(change["new"]) + + def _render(self): + raise NotImplementedError() + + def _post_render(self): + pass + + def _update_background_color(self, state: WizardAppWidgetStep.State): + self.remove_class(self._background_class) + self._background_class = f"qe-app-step-{state.name.lower()}" + self.add_class(self._background_class) + + def _update_state(self): + pass + + +class QeConfirmableWizardStepModel( + QeWizardStepModel, + Confirmable, + HasBlockers, +): + blockers = tl.List(tl.Unicode()) + blocker_messages = tl.Unicode("") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.confirmation_exceptions += [ + "blockers", + "blocker_messages", + ] + + @property + def is_blocked(self): + return any(self.blockers) + + def update_blockers(self): + blockers = list(self._check_blockers()) + if isinstance(self, HasModels): + for _, model in self.get_models(): + if isinstance(model, HasBlockers): + blockers += model.blockers + self.blockers = blockers + + def update_blocker_messages(self): + if self.is_blocked: + formatted = "\n".join(f"
  • {item}
  • " for item in self.blockers) + self.blocker_messages = f""" +
    + The step is blocked due to the following reason(s): +
      + {formatted} +
    +
    + """ + else: + self.blocker_messages = "" + + def _check_blockers(self): + raise NotImplementedError + + +CWSM = t.TypeVar("CWSM", bound=QeConfirmableWizardStepModel) + + +class QeConfirmableWizardStep(QeWizardStep[CWSM]): + def __init__( + self, + model: CWSM, + confirm_kwargs=None, + **kwargs, + ): + super().__init__(model, **kwargs) + self._model.observe( + self._on_confirmation_change, + "confirmed", + ) + self._model.observe( + self._on_blockers_change, + "blockers", + ) + + if confirm_kwargs is None: + confirm_kwargs = {} + + self.confirm_button_style = confirm_kwargs.get("button_style", "success") + self.confirm_button_icon = confirm_kwargs.get("icon", "check-circle") + self.confirm_button_description = confirm_kwargs.get("description", "Confirm") + self.confirm_button_tooltip = confirm_kwargs.get("tooltip", "Confirm") + + def _render(self): + self.content = ipw.VBox() + + self.confirm_button = ipw.Button( + description=self.confirm_button_description, + tooltip=self.confirm_button_tooltip, + button_style=self.confirm_button_style, + icon=self.confirm_button_icon, + layout=ipw.Layout(width="auto"), + disabled=not self._model.is_blocked, + ) + ipw.dlink( + (self, "state"), + (self.confirm_button, "disabled"), + lambda state: self._model.is_blocked or state != self.State.CONFIGURED, + ) + self.confirm_button.on_click(self.confirm) + + self.blocker_messages = ipw.HTML() + self.blocker_messages.add_class("blocker-messages") + ipw.dlink( + (self._model, "blocker_messages"), + (self.blocker_messages, "value"), + ) + + self.confirm_box = ipw.VBox( + children=[ + self.confirm_button, + self.blocker_messages, + ] + ) + self.children += (self.confirm_box,) + + def _on_confirmation_change(self, _): + self._update_state() + + def _on_blockers_change(self, _): + if self.rendered: + self._enable_confirm_button() + self._model.update_blocker_messages() + self._update_state() + + def confirm(self, _=None): + self._model.confirm() + + def _enable_confirm_button(self): + can_confirm = self._model.is_blocked or self.state != self.State.CONFIGURED + self.confirm_button.disabled = can_confirm + + +class QeDependentWizardStep(QeWizardStep[WSM]): + missing_information_warning = "Missing information" + + previous_step_state = tl.UseEnum(WizardAppWidgetStep.State) + + def __init__(self, model: WSM, **kwargs): + super().__init__(model, **kwargs) + self.previous_children = list(self.children) + self.warning_message = ipw.HTML( + f""" +
    + Warning: {self.missing_information_warning} +
    + """ + ) + + def render(self): + if "PYTEST_CURRENT_TEST" in os.environ: + super().render() + return + if self.previous_step_state is WizardAppWidgetStep.State.SUCCESS: + self._hide_missing_information_warning() + if not self.rendered: + super().render() + self.previous_children = list(self.children) + else: + self._show_missing_information_warning() + + @tl.observe("previous_step_state") + def _on_previous_step_state_change(self, _): + self._update_state() + + def _show_missing_information_warning(self): + self.children = [self.warning_message] + self.rendered = False + + def _hide_missing_information_warning(self): + self.children = self.previous_children + + +class QeConfirmableDependentWizardStep( + QeDependentWizardStep[CWSM], + QeConfirmableWizardStep[CWSM], +): + pass diff --git a/tests/conftest.py b/tests/conftest.py index 3df3a57e3..a49feda48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -418,14 +418,14 @@ def _smearing_settings_generator(**kwargs): @pytest.fixture def app(pw_code, dos_code, projwfc_code, projwfc_bands_code): - app = WizardApp(qe_auto_setup=False) + app = WizardApp(auto_setup=False) - # Since we use `qe_auto_setup=False`, which will skip the pseudo library + # Since we use `auto_setup=False`, which will skip the pseudo library # installation, we need to mock set the installation status to `True` to # avoid the blocker message pop up in the submission step. + app.structure_model.installing_sssp = False + app.structure_model.sssp_installed = True app.submit_model.installing_qe = False - app.submit_model.installing_sssp = False - app.submit_model.sssp_installed = True app.submit_model.qe_installed = True # set up codes diff --git a/tests/test_app.py b/tests/test_app.py index 92ad482ac..3ecf4ddcd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,7 +2,7 @@ def test_reload_and_reset(generate_qeapp_workchain): - app = WizardApp(qe_auto_setup=False) + app = WizardApp(auto_setup=False) workchain = generate_qeapp_workchain( relax_type="positions", spin_type="collinear", @@ -30,25 +30,3 @@ def test_selecting_new_structure_unconfirms_model(generate_structure_data): model.confirm() model.input_structure = generate_structure_data() assert not model.confirmed - - -def test_unsaved_changes(app_to_submit): - """Test if the unsaved changes are handled correctly""" - from aiidalab_widgets_base import WizardAppWidgetStep - - app: WizardApp = app_to_submit - # go to the configure step, and make some changes - app._wizard_app_widget.selected_index = 1 - app.configure_model.relax_type = "positions" - # go to the submit step - app._wizard_app_widget.selected_index = 2 - # the state of the configure step should be updated. - assert app.configure_step.state == WizardAppWidgetStep.State.CONFIGURED - # check if a new blocker is added - assert len(app.submit_model.external_submission_blockers) == 1 - # confirm the changes - app._wizard_app_widget.selected_index = 1 - app.configure_model.confirm() - app._wizard_app_widget.selected_index = 2 - # the blocker should be removed - assert len(app.submit_model.external_submission_blockers) == 0 diff --git a/tests/test_codes.py b/tests/test_codes.py index 09ee9f349..1af1cd9eb 100644 --- a/tests/test_codes.py +++ b/tests/test_codes.py @@ -18,7 +18,7 @@ def test_set_selected_codes(submit_app_generator): app: WizardApp = submit_app_generator() parameters = app.submit_model.get_model_state() model = SubmissionStepModel() - _ = SubmitQeAppWorkChainStep(model=model, qe_auto_setup=False) + _ = SubmitQeAppWorkChainStep(model=model, auto_setup=False) for identifier, code_model in app.submit_model.get_model("global").get_models(): model.get_model("global").get_model(identifier).is_active = code_model.is_active model.qe_installed = True @@ -42,28 +42,28 @@ def test_update_codes_display(app: WizardApp): assert global_resources.code_widgets["dos"].layout.display == "block" -def test_check_submission_blockers(app: WizardApp): +def test_check_blockers(app: WizardApp): """Test check_submission_blockers method.""" model = app.submit_model - model.update_submission_blockers() - assert len(model.internal_submission_blockers) == 0 + model.update_blockers() + assert len(model.blockers) == 0 model.input_parameters = {"workchain": {"properties": ["pdos"]}} - model.update_submission_blockers() - assert len(model.internal_submission_blockers) == 0 + model.update_blockers() + assert len(model.blockers) == 0 # set dos code to None, will introduce another blocker dos_code = model.get_model("global").get_model("quantumespresso__dos") dos_value = dos_code.selected dos_code.selected = None - model.update_submission_blockers() - assert len(model.internal_submission_blockers) == 1 + model.update_blockers() + assert len(model.blockers) == 1 # set dos code back will remove the blocker dos_code.selected = dos_value - model.update_submission_blockers() - assert len(model.internal_submission_blockers) == 0 + model.update_blockers() + assert len(model.blockers) == 0 def test_qeapp_computational_resources_widget(app: WizardApp): diff --git a/tests/test_submit_qe_workchain.py b/tests/test_submit_qe_workchain.py index 686981762..0ce85d3d0 100644 --- a/tests/test_submit_qe_workchain.py +++ b/tests/test_submit_qe_workchain.py @@ -153,7 +153,7 @@ def test_warning_messages( pw_code.num_cpus = len(os.sched_getaffinity(0)) global_model.check_resources() for suggestion in ["avoid_overloading", "go_remote"]: - assert suggestions[suggestion] in submit_model.submission_warning_messages + assert suggestions[suggestion] in submit_model.warning_messages # now we use a large structure, so we should have the Warning-1 (and 2 if not on localhost) structure = generate_structure_data("H2O-larger") @@ -165,7 +165,7 @@ def test_warning_messages( estimated_CPUs = global_model._estimate_min_cpus(num_sites, volume) assert estimated_CPUs == 2 for suggestion in ["more_resources", "change_configuration"]: - assert suggestions[suggestion] in submit_model.submission_warning_messages + assert suggestions[suggestion] in submit_model.warning_messages def builder_to_readable_dict(builder):