diff --git a/activity_browser/actions/__init__.py b/activity_browser/actions/__init__.py new file mode 100644 index 000000000..681afec5b --- /dev/null +++ b/activity_browser/actions/__init__.py @@ -0,0 +1,50 @@ +from .activity.activity_relink import ActivityRelink +from .activity.activity_new import ActivityNew +from .activity.activity_duplicate import ActivityDuplicate +from .activity.activity_open import ActivityOpen +from .activity.activity_graph import ActivityGraph +from .activity.activity_duplicate_to_loc import ActivityDuplicateToLoc +from .activity.activity_delete import ActivityDelete +from .activity.activity_duplicate_to_db import ActivityDuplicateToDB + +from .calculation_setup.cs_new import CSNew +from .calculation_setup.cs_delete import CSDelete +from .calculation_setup.cs_duplicate import CSDuplicate +from .calculation_setup.cs_rename import CSRename + +from .database.database_import import DatabaseImport +from .database.database_export import DatabaseExport +from .database.database_new import DatabaseNew +from .database.database_delete import DatabaseDelete +from .database.database_duplicate import DatabaseDuplicate +from .database.database_relink import DatabaseRelink + +from .exchange.exchange_new import ExchangeNew +from .exchange.exchange_delete import ExchangeDelete +from .exchange.exchange_modify import ExchangeModify +from .exchange.exchange_formula_remove import ExchangeFormulaRemove +from .exchange.exchange_uncertainty_modify import ExchangeUncertaintyModify +from .exchange.exchange_uncertainty_remove import ExchangeUncertaintyRemove +from .exchange.exchange_copy_sdf import ExchangeCopySDF + +from .method.method_duplicate import MethodDuplicate +from .method.method_delete import MethodDelete + +from .method.cf_uncertainty_modify import CFUncertaintyModify +from .method.cf_amount_modify import CFAmountModify +from .method.cf_remove import CFRemove +from .method.cf_new import CFNew +from .method.cf_uncertainty_remove import CFUncertaintyRemove + +from .parameter.parameter_new import ParameterNew +from .parameter.parameter_new_automatic import ParameterNewAutomatic +from .parameter.parameter_rename import ParameterRename + +from .project.project_new import ProjectNew +from .project.project_duplicate import ProjectDuplicate +from .project.project_delete import ProjectDelete + +from .default_install import DefaultInstall +from .biosphere_update import BiosphereUpdate +from .plugin_wizard_open import PluginWizardOpen +from .settings_wizard_open import SettingsWizardOpen diff --git a/activity_browser/actions/activity/activity_delete.py b/activity_browser/actions/activity/activity_delete.py new file mode 100644 index 000000000..4dd82fa0d --- /dev/null +++ b/activity_browser/actions/activity/activity_delete.py @@ -0,0 +1,45 @@ +from typing import Union, Callable, List + +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, activity_controller +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction + + +class ActivityDelete(ABAction): + """ + ABAction to delete one or multiple activities if supplied by activity keys. Will check if an activity has any + downstream processes and ask the user whether they want to continue if so. Exchanges from any downstream processes + will be removed + """ + icon = qicons.delete + title = 'Delete ***' + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + # retrieve activity objects from the controller using the provided keys + activities = activity_controller.get_activities(self.activity_keys) + + # check for downstream processes + if any(len(act.upstream()) > 0 for act in activities): + # warning text + text = ("One or more activities have downstream processes. Deleting these activities will remove the " + "exchange from the downstream processes, this can't be undone.\n\nAre you sure you want to " + "continue?") + + # alert the user + choice = QtWidgets.QMessageBox.warning(application.main_window, + "Activity/Activities has/have downstream processes", + text, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No) + + # return if the user cancels + if choice == QtWidgets.QMessageBox.No: return + + # use the activity controller to delete multiple activities + activity_controller.delete_activities(self.activity_keys) diff --git a/activity_browser/actions/activity/activity_duplicate.py b/activity_browser/actions/activity/activity_duplicate.py new file mode 100644 index 000000000..f80f86456 --- /dev/null +++ b/activity_browser/actions/activity/activity_duplicate.py @@ -0,0 +1,24 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import activity_controller +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction + + +class ActivityDuplicate(ABAction): + """ + Duplicate one or multiple activities using their keys. Proxy action to call the controller. + """ + icon = qicons.copy + title = 'Duplicate ***' + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + activity_controller.duplicate_activities(self.activity_keys) + + diff --git a/activity_browser/actions/activity/activity_duplicate_to_db.py b/activity_browser/actions/activity/activity_duplicate_to_db.py new file mode 100644 index 000000000..e55e810ed --- /dev/null +++ b/activity_browser/actions/activity/activity_duplicate_to_db.py @@ -0,0 +1,56 @@ +from typing import Union, Callable, List + +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, project_settings, activity_controller +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction + + +class ActivityDuplicateToDB(ABAction): + """ + ABAction to duplicate an activity to another database. Asks the user to what database they want to copy the activity + to, returns if there are no valid databases or when the user cancels. Otherwise uses the activity controller to + duplicate the activities to the chosen database. + """ + icon = qicons.duplicate_to_other_database + title = 'Duplicate to other database' + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + # get bw activity objects from keys + activities = activity_controller.get_activities(self.activity_keys) + + # get valid databases (not the original database, or locked databases) + origin_db = next(iter(activities)).get("database") + target_dbs = [db for db in project_settings.get_editable_databases() if db != origin_db] + + # return if there are no valid databases to duplicate to + if not target_dbs: + QtWidgets.QMessageBox.warning( + application.main_window, + "No target database", + "No valid target databases available. Create a new database or set one to writable (not read-only)." + ) + return + + # construct a dialog where the user can choose a database to duplicate to + target_db, ok = QtWidgets.QInputDialog.getItem( + application.main_window, + "Copy activity to database", + "Target database:", + target_dbs, + 0, + False + ) + + # return if the user didn't choose, or canceled + if not target_db or not ok: return + + # otherwise move all supplied activities to the db using the controller + for activity in activities: + activity_controller.duplicate_activity_to_db(target_db, activity) + diff --git a/activity_browser/actions/activity/activity_duplicate_to_loc.py b/activity_browser/actions/activity/activity_duplicate_to_loc.py new file mode 100644 index 000000000..6520565e9 --- /dev/null +++ b/activity_browser/actions/activity/activity_duplicate_to_loc.py @@ -0,0 +1,170 @@ +from typing import Union, Callable, Optional + +import pandas as pd +import brightway2 as bw +from PySide2 import QtCore + +from activity_browser import signals, application, activity_controller, exchange_controller +from activity_browser.bwutils import AB_metadata +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction +from ...ui.widgets import LocationLinkingDialog + + +class ActivityDuplicateToLoc(ABAction): + """ + ABAction to duplicate an activity and possibly their exchanges to a new location. + """ + icon = qicons.copy + title = 'Duplicate activity to new location' + activity_key: tuple + db_name: str + + def __init__(self, activity_key: Union[tuple, Callable], parent: QtCore.QObject): + super().__init__(parent, activity_key=activity_key) + + def onTrigger(self, toggled): + act = activity_controller.get_activities(self.activity_key)[0] + self.db_name = act.key[0] + + # get list of dependent databases for activity and load to MetaDataStore + databases = [] + for exchange in act.technosphere(): + databases.append(exchange.input[0]) + if self.db_name not in databases: # add own database if it wasn't added already + databases.append(self.db_name) + + # load all dependent databases to MetaDataStore + dbs = {db: AB_metadata.get_database_metadata(db) for db in databases} + # get list of all unique locations in the dependent databases (sorted alphabetically) + locations = [] + for db in dbs.values(): + locations += db['location'].to_list() # add all locations to one list + locations = list(set(locations)) # reduce the list to only unique items + locations.sort() + + # get the location to relink + db = dbs[self.db_name] + old_location = db.loc[db['key'] == act.key]['location'].iloc[0] + + # trigger dialog with autocomplete-writeable-dropdown-list + options = (old_location, locations) + dialog = LocationLinkingDialog.relink_location(act['name'], options, application.main_window) + + if dialog.exec_() != LocationLinkingDialog.Accepted: return + + # read the data from the dialog + for old, new in dialog.relink.items(): + alternatives = [] + new_location = new + if dialog.use_rer.isChecked(): # RER + alternatives.append(dialog.use_rer.text()) + if dialog.use_ews.isChecked(): # Europe without Switzerland + alternatives.append(dialog.use_ews.text()) + if dialog.use_row.isChecked(): # RoW + alternatives.append(dialog.use_row.text()) + # the order we add alternatives is important, they are checked in this order! + if len(alternatives) > 0: + use_alternatives = True + else: + use_alternatives = False + + successful_links = {} # dict of dicts, key of new exch : {new values} <-- see 'values' below + # in the future, 'alternatives' could be improved by making use of some location hierarchy. From that we could + # get things like if the new location is NL but there is no NL, but RER exists, we use that. However, for that + # we need some hierarchical structure to the location data, which may be available from ecoinvent, but we need + # to look for that. + + # get exchanges that we want to relink + for exch in act.technosphere(): + candidate = self.find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) + if candidate is None: + continue # no suitable candidate was found, try the next exchange + + # at this point, we have found 1 suitable candidate, whether that is new_location or alternative location + values = { + 'amount': exch.get('amount', False), + 'comment': exch.get('comment', False), + 'formula': exch.get('formula', False), + 'uncertainty': exch.get('uncertainty', False) + } + successful_links[candidate['key'].iloc[0]] = values + + # now, create a new activity by copying the old one + new_code = activity_controller.generate_copy_code(act.key) + new_act = act.copy(new_code) + # update production exchanges + for exc in new_act.production(): + if exc.input.key == act.key: + exc.input = new_act + exc.save() + # update 'products' + for product in new_act.get('products', []): + if product.get('input') == act.key: + product.input = new_act.key + new_act.save() + # save the new location to the activity + activity_controller.modify_activity(new_act.key, 'location', new_location) + + # get exchanges that we want to delete + del_exch = [] # delete these exchanges + for exch in new_act.technosphere(): + candidate = self.find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) + if candidate is None: + continue # no suitable candidate was found, try the next exchange + del_exch.append(exch) + # delete exchanges with old locations + exchange_controller.delete_exchanges(del_exch) + + # add the new exchanges with all values carried over from last exchange + exchange_controller.add_exchanges(list(successful_links.keys()), new_act.key, successful_links) + + # update the MetaDataStore and open new activity + AB_metadata.update_metadata(new_act.key) + signals.safe_open_activity_tab.emit(new_act.key) + + # send signals to relevant locations + bw.databases.set_modified(self.db_name) + signals.database_changed.emit(self.db_name) + signals.databases_changed.emit() + + def find_candidate(self, dbs, exch, old_location, new_location, use_alternatives, alternatives) -> Optional[object]: + """Find a candidate to replace the exchange with.""" + current_db = exch.input[0] + if current_db == self.db_name: + db = dbs[current_db] + else: # if the exchange is not from the current database, also check the current + # (user may have added their own alternative dependents already) + db = pd.concat([dbs[current_db], dbs[self.db_name]]) + + if db.loc[db['key'] == exch.input]['location'].iloc[0] != old_location: + return # this exchange has a location we're not trying to re-link + + # get relevant data to match on + row = db.loc[db['key'] == exch.input] + name = row['name'].iloc[0] + prod = row['reference product'].iloc[0] + unit = row['unit'].iloc[0] + + # get candidates to match (must have same name, product and unit) + candidates = db.loc[(db['name'] == name) + & (db['reference product'] == prod) + & (db['unit'] == unit)] + if len(candidates) <= 1: + return # this activity does not exist in this database with another location (1 is self) + + # check candidates for new_location + candidate = candidates.loc[candidates['location'] == new_location] + if len(candidate) == 0 and not use_alternatives: + return # there is no candidate + elif len(candidate) > 1: + return # there is more than one candidate, we can't know what to use + elif len(candidate) == 0: + # there are no candidates, but we can try alternatives + for alt in alternatives: + candidate = candidates.loc[candidates['location'] == alt] + if len(candidate) == 1: + break # found an alternative in with this alternative location, stop looking + if len(candidate) != 1: + return # there are either no or multiple matches with alternative locations + return candidate diff --git a/activity_browser/actions/activity/activity_graph.py b/activity_browser/actions/activity/activity_graph.py new file mode 100644 index 000000000..a745a107d --- /dev/null +++ b/activity_browser/actions/activity/activity_graph.py @@ -0,0 +1,23 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import signals +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class ActivityGraph(ABAction): + """ + ABAction to open one or multiple activities in the graph explorer + """ + icon = qicons.graph_explorer + title = "'Open *** in Graph Explorer'" + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + for key in self.activity_keys: + signals.open_activity_graph_tab.emit(key) diff --git a/activity_browser/actions/activity/activity_new.py b/activity_browser/actions/activity/activity_new.py new file mode 100644 index 000000000..7aa7f724e --- /dev/null +++ b/activity_browser/actions/activity/activity_new.py @@ -0,0 +1,35 @@ +from typing import Union, Callable + +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, activity_controller +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction + + +class ActivityNew(ABAction): + """ + ABAction to create a new activity. Prompts the user to supply a name. Returns if no name is supplied or if the user + cancels. Otherwise, instructs the ActivityController to create a new activity. + """ + icon = qicons.add + title = "New activity" + database_name: str + + def __init__(self, database_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, database_name=database_name) + + def onTrigger(self, toggled): + # ask the user to provide a name for the new activity + name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Create new technosphere activity", + "Please specify an activity name:" + " " * 10, + QtWidgets.QLineEdit.Normal + ) + + # if no name is provided, or the user cancels, return + if not ok or not name: return + + # else, instruct the ActivityController to create a new activity + activity_controller.new_activity(self.database_name, name) diff --git a/activity_browser/actions/activity/activity_open.py b/activity_browser/actions/activity/activity_open.py new file mode 100644 index 000000000..f602faf77 --- /dev/null +++ b/activity_browser/actions/activity/activity_open.py @@ -0,0 +1,26 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import signals +from activity_browser.ui.icons import qicons +from activity_browser.actions.base import ABAction + + +class ActivityOpen(ABAction): + """ + ABAction to open one or more supplied activities in an activity tab by employing signals. + + TODO: move away from using signals like this. Probably add a method to the MainWindow to add a panel instead. + """ + icon = qicons.right + title = 'Open ***' + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + for key in self.activity_keys: + signals.safe_open_activity_tab.emit(key) + signals.add_activity_to_history.emit(key) diff --git a/activity_browser/actions/activity/activity_relink.py b/activity_browser/actions/activity/activity_relink.py new file mode 100644 index 000000000..6bff1b9ac --- /dev/null +++ b/activity_browser/actions/activity/activity_relink.py @@ -0,0 +1,73 @@ +from typing import Union, Callable, List + +import brightway2 as bw +from PySide2 import QtWidgets, QtCore + +from activity_browser import signals, application +from activity_browser.bwutils.strategies import relink_activity_exchanges +from activity_browser.actions.base import ABAction +from activity_browser.ui.widgets import ActivityLinkingDialog, ActivityLinkingResultsDialog +from activity_browser.ui.icons import qicons + + +class ActivityRelink(ABAction): + """ + ABAction to relink the exchanges of an activity to exchanges from another database. + + This action only uses the first key from activity_keys + """ + icon = qicons.edit + title = "Relink the activity exchanges" + activity_keys: List[tuple] + + def __init__(self, activity_keys: Union[List[tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + # this action only uses the first key supplied to activity_keys + key = self.activity_keys[0] + + # extract the brightway database and activity + db = bw.Database(key[0]) + activity = db.get(key[1]) + + # find the dependents for the database and construct the alternatives in tuple format + depends = db.find_dependents() + options = [(depend, bw.databases.list) for depend in depends] + + # present the alternatives to the user in a linking dialog + dialog = ActivityLinkingDialog.relink_sqlite( + activity['name'], + options, + application.main_window + ) + + # return if the user cancels + if dialog.exec_() == ActivityLinkingDialog.Rejected: return + + # relinking will take some time, set WaitCursor + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + + # use the relink_activity_exchanges strategy to relink the exchanges of the activity + relinking_results = {} + for old, new in dialog.relink.items(): + other = bw.Database(new) + failed, succeeded, examples = relink_activity_exchanges(activity, old, other) + relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) + + # restore normal cursor + QtWidgets.QApplication.restoreOverrideCursor() + + # if any relinks failed present them to the user + if failed > 0: + relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results( + application.main_window, + relinking_results, + examples + ) + relinking_dialog.exec_() + activity = relinking_dialog.open_activity() + + # TODO signals should be owned by controllers: refactor + signals.database_changed.emit(activity['name']) + signals.databases_changed.emit() diff --git a/activity_browser/actions/base.py b/activity_browser/actions/base.py new file mode 100644 index 000000000..61c1b5507 --- /dev/null +++ b/activity_browser/actions/base.py @@ -0,0 +1,41 @@ +from PySide2 import QtWidgets, QtGui, QtCore + + +class ABAction(QtWidgets.QAction): + icon: QtGui.QIcon + title: str + tool_tip: str = None + + def __init__(self, parent, **kwargs): + super().__init__(self.icon, self.title, parent) + self.kwargs = kwargs + + self.triggered.connect(self.onTrigger) + self.toggled.connect(self.onToggle) + + if self.tool_tip: + self.setToolTip(self.tool_tip) + + def __getattr__(self, name: str): + # immediate return if not found + if name not in self.kwargs.keys(): + raise AttributeError + + # get the associated value + value = self.kwargs[name] + + # if the kwarg is a getter, call and return, else just return + if callable(value): return value() + else: return value + + def onTrigger(self, checked): + raise NotImplementedError + + def onToggle(self, checked): + raise NotImplementedError + + def button(self) -> QtWidgets.QToolButton: + button = QtWidgets.QToolButton(self.parent()) + button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + button.setDefaultAction(self) + return button diff --git a/activity_browser/actions/biosphere_update.py b/activity_browser/actions/biosphere_update.py new file mode 100644 index 000000000..db9e327d3 --- /dev/null +++ b/activity_browser/actions/biosphere_update.py @@ -0,0 +1,48 @@ +from typing import Union, Callable, Any + +from PySide2 import QtCore, QtWidgets + +from activity_browser import application +from .base import ABAction +from ..ui.widgets import BiosphereUpdater, EcoinventVersionDialog +from ..ui.icons import qicons +from ..utils import sort_semantic_versions +from ..info import __ei_versions__ + + +class BiosphereUpdate(ABAction): + """ + ABAction to open the Biosphere updater. + """ + icon = application.style().standardIcon(QtWidgets.QStyle.SP_BrowserReload) + title = "Update biosphere..." + + updater: BiosphereUpdater + + def onTrigger(self, toggled): + """ Open a popup with progression bar and run through the different + functions for adding ecoinvent biosphere flows. + """ + # warn user of consequences of updating + warn_dialog = QtWidgets.QMessageBox.question( + application.main_window, "Update biosphere3?", + 'Newer versions of the biosphere database may not\n' + 'always be compatible with older ecoinvent versions.\n' + '\nUpdating the biosphere3 database cannot be undone!\n', + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Abort, + QtWidgets.QMessageBox.Abort + ) + if warn_dialog is not QtWidgets.QMessageBox.Ok: return + + # let user choose version + version_dialog = EcoinventVersionDialog(application.main_window) + if version_dialog.exec_() != EcoinventVersionDialog.Accepted: return + version = version_dialog.options.currentText() + + # reduce biosphere update list up to the selected version + sorted_versions = sort_semantic_versions(__ei_versions__, highest_to_lowest=False) + ei_versions = sorted_versions[:sorted_versions.index(version) + 1] + + # show updating dialog + self.updater = BiosphereUpdater(ei_versions, application.main_window) + self.updater.show() diff --git a/activity_browser/actions/calculation_setup/cs_delete.py b/activity_browser/actions/calculation_setup/cs_delete.py new file mode 100644 index 000000000..4c46d1ef6 --- /dev/null +++ b/activity_browser/actions/calculation_setup/cs_delete.py @@ -0,0 +1,52 @@ +import traceback +from typing import Union, Callable + +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, log +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.controllers import calculation_setup_controller + + +class CSDelete(ABAction): + """ + ABAction to delete a calculation setup. First asks the user for confirmation and returns if cancelled. Otherwise, + passes the csname to the CalculationSetupController for deletion. Finally, displays confirmation that it succeeded. + """ + icon = qicons.delete + title = "Delete" + cs_name: str + + def __init__(self, cs_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, cs_name=cs_name) + + def onTrigger(self, toggled): + # ask the user whether they are sure to delete the calculation setup + warning = QtWidgets.QMessageBox.warning(application.main_window, + f"Deleting Calculation Setup: {self.cs_name}", + "Are you sure you want to delete this calculation setup?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No + ) + + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: return + + try: + calculation_setup_controller.delete_calculation_setup(self.cs_name) + except Exception as e: + log.error(f"Deletion of calculation setup {self.cs_name} failed with error {traceback.format_exc()}") + QtWidgets.QMessageBox.critical(application.main_window, + f"Deleting Calculation Setup: {self.cs_name}", + "An error occured during the deletion of the calculation setup. Check the " + "logs for more information", + QtWidgets.QMessageBox.Ok + ) + return + + QtWidgets.QMessageBox.information(application.main_window, + f"Deleting Calculation Setup: {self.cs_name}", + "Calculation setup was succesfully deleted.", + QtWidgets.QMessageBox.Ok) + diff --git a/activity_browser/actions/calculation_setup/cs_duplicate.py b/activity_browser/actions/calculation_setup/cs_duplicate.py new file mode 100644 index 000000000..2bcd06a35 --- /dev/null +++ b/activity_browser/actions/calculation_setup/cs_duplicate.py @@ -0,0 +1,45 @@ +from typing import Union, Callable + +import brightway2 as bw +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, calculation_setup_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class CSDuplicate(ABAction): + """ + ABAction to duplicate a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS + with the same name is already present within the project. If all is right, instructs the CalculationSetupController + to duplicate the CS. + """ + icon = qicons.copy + title = "Duplicate" + cs_name: str + + def __init__(self, cs_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, cs_name=cs_name) + + def onTrigger(self, toggled): + # prompt the user to give a name for the new calculation setup + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + f"Duplicate '{self.cs_name}'", + "Name of the duplicated calculation setup:" + " " * 10 + ) + + # return if the user cancels or gives no name + if not ok or not new_name: return + + # throw error if the name is already present, and return + if new_name in bw.calculation_setups.keys(): + QtWidgets.QMessageBox.warning( + application.main_window, + "Not possible", + "A calculation setup with this name already exists." + ) + return + + # instruct the CalculationSetupController to duplicate the CS to the new name + calculation_setup_controller.duplicate_calculation_setup(self.cs_name, new_name) diff --git a/activity_browser/actions/calculation_setup/cs_new.py b/activity_browser/actions/calculation_setup/cs_new.py new file mode 100644 index 000000000..fa3bb2e74 --- /dev/null +++ b/activity_browser/actions/calculation_setup/cs_new.py @@ -0,0 +1,40 @@ +import brightway2 as bw +from PySide2 import QtWidgets + +from activity_browser import application +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.controllers import calculation_setup_controller + + +class CSNew(ABAction): + """ + ABAction to create a new Calculation Setup. Prompts the user for a name for the new CS. Returns if the user cancels, + or when a CS with the same name is already present within the project. Otherwise, instructs the CSController to + create a new Calculation Setup with the given name. + """ + icon = qicons.add + title = "New" + + def onTrigger(self, toggled): + # prompt the user to give a name for the new calculation setup + name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Create new calculation setup", + "Name of new calculation setup:" + " " * 10 + ) + + # return if the user cancels or gives no name + if not ok or not name: return + + # throw error if the name is already present, and return + if name in bw.calculation_setups.keys(): + QtWidgets.QMessageBox.warning( + application.main_window, + "Not possible", + "A calculation setup with this name already exists." + ) + return + + # instruct the CalculationSetupController to create a CS with the new name + calculation_setup_controller.new_calculation_setup(name) diff --git a/activity_browser/actions/calculation_setup/cs_rename.py b/activity_browser/actions/calculation_setup/cs_rename.py new file mode 100644 index 000000000..f86cd573d --- /dev/null +++ b/activity_browser/actions/calculation_setup/cs_rename.py @@ -0,0 +1,45 @@ +from typing import Union, Callable + +import brightway2 as bw +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, calculation_setup_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class CSRename(ABAction): + """ + ABAction to rename a calculation setup. Prompts the user for a new name. Returns if the user cancels, or if a CS + with the same name is already present within the project. If all is right, instructs the CalculationSetupController + to rename the CS. + """ + icon = qicons.edit + title = "Rename" + cs_name: str + + def __init__(self, cs_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, cs_name=cs_name) + + def onTrigger(self, toggled): + # prompt the user to give a name for the new calculation setup + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + f"Rename '{self.cs_name}'", + "New name of this calculation setup:" + " " * 10 + ) + + # return if the user cancels or gives no name + if not ok or not new_name: return + + # throw error if the name is already present, and return + if new_name in bw.calculation_setups.keys(): + QtWidgets.QMessageBox.warning( + application.main_window, + "Not possible", + "A calculation setup with this name already exists." + ) + return + + # instruct the CalculationSetupController to rename the CS to the new name + calculation_setup_controller.rename_calculation_setup(self.cs_name, new_name) diff --git a/activity_browser/actions/database/database_delete.py b/activity_browser/actions/database/database_delete.py new file mode 100644 index 000000000..cabfbab2c --- /dev/null +++ b/activity_browser/actions/database/database_delete.py @@ -0,0 +1,39 @@ +from typing import Union, Callable + +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, database_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class DatabaseDelete(ABAction): + """ + ABAction to delete a database from the project. Asks the user for confirmation. If confirmed, instructs the + DatabaseController to delete the database in question. + """ + icon = qicons.delete + title = "Delete database" + tool_tip = "Delete this database from the project" + db_name: str + + def __init__(self, database_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, db_name=database_name) + + def onTrigger(self, toggled): + # get the record count from the database controller + n_records = database_controller.record_count(self.db_name) + + # ask the user for confirmation + response = QtWidgets.QMessageBox.question( + application.main_window, + "Delete database?", + f"Are you sure you want to delete database '{self.db_name}'? It contains {n_records} activities" + ) + + # return if the user cancels + if response != response.Yes: return + + # instruct the DatabaseController to delete the database from the project. + database_controller.delete_database(self.db_name) + diff --git a/activity_browser/actions/database/database_duplicate.py b/activity_browser/actions/database/database_duplicate.py new file mode 100644 index 000000000..ebd7436a1 --- /dev/null +++ b/activity_browser/actions/database/database_duplicate.py @@ -0,0 +1,83 @@ +from typing import Union, Callable + +import brightway2 as bw +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, database_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.ui.threading import ABThread + + +class DatabaseDuplicate(ABAction): + """ + ABAction to duplicate a database. Asks the user to provide a new name for the database, and returns when the name is + already in use by an existing database. Then it shows a progress dialogue which will construct a new thread in which + the database duplication will take place. This thread instructs the DatabaseController to duplicate the selected + database with the chosen name. + """ + icon = qicons.duplicate_database + title = "Duplicate database..." + tool_tip = "Make a duplicate of this database" + db_name: str + + dialog: "DuplicateDatabaseDialog" + + def __init__(self, database_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, db_name=database_name) + + def onTrigger(self, toggled): + assert self.db_name in bw.databases + + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + f"Copy {self.db_name}", + "Name of new database:" + " " * 25 + ) + if not new_name or not ok: return + + if new_name in bw.databases: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible", + "A database with this name already exists." + ) + return + + self.dialog = DuplicateDatabaseDialog( + self.db_name, + new_name, + application.main_window + ) + + +class DuplicateDatabaseDialog(QtWidgets.QProgressDialog): + def __init__(self, from_db: str, to_db: str, parent=None): + super().__init__(parent=parent) + self.setWindowTitle('Duplicating database') + self.setLabelText(f'Duplicating existing database {from_db} to new database {to_db}:') + self.setModal(True) + self.setRange(0, 0) + + self.thread = DuplicateDatabaseThread(from_db, to_db, self) + self.thread.finished.connect(self.finished) + + self.show() + + self.thread.start() + + def finished(self, result: int = None) -> None: + self.thread.exit(result or 0) + self.setMaximum(1) + self.setValue(1) + + +class DuplicateDatabaseThread(ABThread): + def __init__(self, from_db, to_db, parent=None): + super().__init__(parent=parent) + self.copy_from = from_db + self.copy_to = to_db + + def run_safely(self): + database_controller.duplicate_database(self.copy_from, self.copy_to) + diff --git a/activity_browser/actions/database/database_export.py b/activity_browser/actions/database/database_export.py new file mode 100644 index 000000000..e31fe0d3e --- /dev/null +++ b/activity_browser/actions/database/database_export.py @@ -0,0 +1,20 @@ +from PySide2 import QtWidgets + +from activity_browser import application +from activity_browser.actions.base import ABAction +from activity_browser.ui.wizards.db_export_wizard import DatabaseExportWizard + + +class DatabaseExport(ABAction): + """ + ABAction to open the DatabaseExportWizard. + """ + icon = application.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon) + title = "Export database..." + tool_tip = "Export a database from this project" + + wizard: DatabaseExportWizard + + def onTrigger(self, toggled): + self.wizard = DatabaseExportWizard(application.main_window) + self.wizard.show() diff --git a/activity_browser/actions/database/database_import.py b/activity_browser/actions/database/database_import.py new file mode 100644 index 000000000..a4ea8cf86 --- /dev/null +++ b/activity_browser/actions/database/database_import.py @@ -0,0 +1,16 @@ +from activity_browser import application +from activity_browser.actions.base import ABAction +from activity_browser.ui.wizards.db_import_wizard import DatabaseImportWizard +from activity_browser.ui.icons import qicons + + +class DatabaseImport(ABAction): + """ABAction to open the DatabaseImportWizard""" + icon = qicons.import_db + title = "Import database..." + tool_tip = "Import a new database" + wizard: DatabaseImportWizard + + def onTrigger(self, toggled): + self.wizard = DatabaseImportWizard(application.main_window) + self.wizard.show() diff --git a/activity_browser/actions/database/database_new.py b/activity_browser/actions/database/database_new.py new file mode 100644 index 000000000..4627f6cf2 --- /dev/null +++ b/activity_browser/actions/database/database_new.py @@ -0,0 +1,39 @@ +from typing import Union, Callable, Any + +import brightway2 as bw +from PySide2 import QtWidgets + +from activity_browser import application +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.controllers import database_controller + + +class DatabaseNew(ABAction): + """ + ABAction to create a new database. First asks the user to provide a name for the new database. Returns if the user + cancels, or when an existing database already has the chosen name. Otherwise, instructs the controller to create a + new database with the chosen name. + """ + icon = qicons.add + title = "New database..." + tool_tip = "Make a new database" + + def onTrigger(self, toggled): + name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Create new database", + "Name of new database:" + " " * 25 + ) + + if not ok or not name: return + + if name in bw.databases: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible", + "A database with this name already exists." + ) + return + + database_controller.new_database(name) diff --git a/activity_browser/actions/database/database_relink.py b/activity_browser/actions/database/database_relink.py new file mode 100644 index 000000000..537fbb21d --- /dev/null +++ b/activity_browser/actions/database/database_relink.py @@ -0,0 +1,60 @@ +from typing import Union, Callable + +import brightway2 as bw +from PySide2 import QtWidgets, QtCore + +from activity_browser import application, signals +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.ui.widgets import DatabaseLinkingDialog, DatabaseLinkingResultsDialog +from activity_browser.bwutils.strategies import relink_exchanges_existing_db + + +class DatabaseRelink(ABAction): + """ + ABAction to relink the dependencies of a database. + """ + icon = qicons.edit + title = "Relink the database" + tool_tip = "Relink the dependencies of this database" + db_name: str + + def __init__(self, database_name: Union[str, Callable], parent: QtCore.QObject): + super().__init__(parent, db_name=database_name) + + def onTrigger(self, toggled): + # get brightway database object + db = bw.Database(self.db_name) + + # find the dependencies of the database and construct a list of suitable candidates + depends = db.find_dependents() + options = [(depend, bw.databases.list) for depend in depends] + + # construct a dialog in which the user chan choose which depending database to connect to which candidate + dialog = DatabaseLinkingDialog.relink_sqlite(self.db_name, options, application.main_window) + + # return if the user cancels + if dialog.exec_() != DatabaseLinkingDialog.Accepted: return + + # else, start the relinking + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + relinking_results = dict() + + # relink using relink_exchanges_existing_db strategy + for old, new in dialog.relink.items(): + other = bw.Database(new) + failed, succeeded, examples = relink_exchanges_existing_db(db, old, other) + relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) + + QtWidgets.QApplication.restoreOverrideCursor() + + # if any failed, present user with results dialog + if failed > 0: + relinking_dialog = DatabaseLinkingResultsDialog.present_relinking_results(application.main_window, + relinking_results, examples) + relinking_dialog.exec_() + relinking_dialog.open_activity() + + # TODO move refactor so signals are owned by controllers instead + signals.database_changed.emit(self.db_name) + signals.databases_changed.emit() diff --git a/activity_browser/actions/default_install.py b/activity_browser/actions/default_install.py new file mode 100644 index 000000000..7dcba4d22 --- /dev/null +++ b/activity_browser/actions/default_install.py @@ -0,0 +1,22 @@ +from activity_browser import application +from .base import ABAction +from ..ui.widgets import DefaultBiosphereDialog, EcoinventVersionDialog +from ..ui.icons import qicons + + +class DefaultInstall(ABAction): + """ + ABAction to install all the default data: biosphere, IC's etcetera. + """ + icon = qicons.import_db + title = "Add default data (biosphere flows and impact categories)" + + dialog: DefaultBiosphereDialog + + def onTrigger(self, toggled): + version_dialog = EcoinventVersionDialog(application.main_window) + if version_dialog.exec_() != EcoinventVersionDialog.Accepted: return + version = version_dialog.options.currentText() + + self.dialog = DefaultBiosphereDialog(version[:3], application.main_window) # only read Major/Minor part of version + self.dialog.show() diff --git a/activity_browser/actions/exchange/exchange_copy_sdf.py b/activity_browser/actions/exchange/exchange_copy_sdf.py new file mode 100644 index 000000000..8a081507a --- /dev/null +++ b/activity_browser/actions/exchange/exchange_copy_sdf.py @@ -0,0 +1,25 @@ +from typing import Union, Callable, List, Any + +import pandas as pd +from PySide2 import QtCore + +from ...bwutils import commontasks +from ..base import ABAction +from ...ui.icons import qicons + + +class ExchangeCopySDF(ABAction): + """ + ABAction to copy the exchange information in SDF format to the clipboard. + """ + icon = qicons.superstructure + title = "Exchanges for scenario difference file" + exchanges: List[Any] + + def __init__(self, exchanges: Union[List[Any], Callable], parent: QtCore.QObject): + super().__init__(parent, exchanges=exchanges) + + def onTrigger(self, toggled): + data = commontasks.get_exchanges_in_scenario_difference_file_notation(self.exchanges) + df = pd.DataFrame(data) + df.to_clipboard(excel=True, index=False) diff --git a/activity_browser/actions/exchange/exchange_delete.py b/activity_browser/actions/exchange/exchange_delete.py new file mode 100644 index 000000000..0d84a4dcc --- /dev/null +++ b/activity_browser/actions/exchange/exchange_delete.py @@ -0,0 +1,22 @@ +from typing import Union, Callable, List, Any + +from PySide2 import QtCore + +from activity_browser import exchange_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class ExchangeDelete(ABAction): + """ + ABAction to delete one or more exchanges from an activity. + """ + icon = qicons.delete + title = "Delete exchange(s)" + exchanges: List[Any] + + def __init__(self, exchanges: Union[List[Any], Callable], parent: QtCore.QObject): + super().__init__(parent, exchanges=exchanges) + + def onTrigger(self, toggled): + exchange_controller.delete_exchanges(self.exchanges) diff --git a/activity_browser/actions/exchange/exchange_formula_remove.py b/activity_browser/actions/exchange/exchange_formula_remove.py new file mode 100644 index 000000000..4f2b4758d --- /dev/null +++ b/activity_browser/actions/exchange/exchange_formula_remove.py @@ -0,0 +1,23 @@ +from typing import Union, Callable, List, Any + +from PySide2 import QtCore + +from activity_browser import exchange_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class ExchangeFormulaRemove(ABAction): + """ + ABAction to clear the formula's of one or more exchanges. + """ + icon = qicons.delete + title = "Clear formula(s)" + exchanges: List[Any] + + def __init__(self, exchanges: Union[List[Any], Callable], parent: QtCore.QObject): + super().__init__(parent, exchanges=exchanges) + + def onTrigger(self, toggled): + for exchange in self.exchanges: + exchange_controller.edit_exchange(exchange, {"formula": None}) diff --git a/activity_browser/actions/exchange/exchange_modify.py b/activity_browser/actions/exchange/exchange_modify.py new file mode 100644 index 000000000..f95f5e461 --- /dev/null +++ b/activity_browser/actions/exchange/exchange_modify.py @@ -0,0 +1,23 @@ +from typing import Union, Callable, Any + +from PySide2 import QtCore + +from activity_browser import exchange_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class ExchangeModify(ABAction): + """ + ABAction to modify an exchange with the supplied data. + """ + icon = qicons.delete + title = "Delete exchange(s)" + exchange: Any + data_: dict + + def __init__(self, exchange: Union[Any, Callable], data: Union[dict, callable], parent: QtCore.QObject): + super().__init__(parent, exchange=exchange, data_=data) + + def onTrigger(self, toggled): + exchange_controller.edit_exchange(self.exchange, self.data_) diff --git a/activity_browser/actions/exchange/exchange_new.py b/activity_browser/actions/exchange/exchange_new.py new file mode 100644 index 000000000..c747070de --- /dev/null +++ b/activity_browser/actions/exchange/exchange_new.py @@ -0,0 +1,27 @@ +from typing import Union, Callable, List, Optional + +from PySide2 import QtCore + +from ..base import ABAction +from ...ui.icons import qicons +from ...controllers import exchange_controller + + +class ExchangeNew(ABAction): + """ + ABAction to create a new exchange for an activity. + """ + icon = qicons.add + title = "Add exchanges" + from_keys: List[tuple] + to_key: tuple + + def __init__(self, + from_keys: Union[List[tuple], Callable], + to_key: Union[tuple, Callable], + parent: QtCore.QObject + ): + super().__init__(parent, from_keys=from_keys, to_key=to_key) + + def onTrigger(self, toggled): + exchange_controller.add_exchanges(self.from_keys, self.to_key, None) diff --git a/activity_browser/actions/exchange/exchange_uncertainty_modify.py b/activity_browser/actions/exchange/exchange_uncertainty_modify.py new file mode 100644 index 000000000..c5f3fb539 --- /dev/null +++ b/activity_browser/actions/exchange/exchange_uncertainty_modify.py @@ -0,0 +1,25 @@ +from typing import Union, Callable, List, Any + +from PySide2 import QtCore + +from activity_browser import application +from activity_browser.ui.wizards import UncertaintyWizard +from ..base import ABAction +from ...ui.icons import qicons + + +class ExchangeUncertaintyModify(ABAction): + """ + ABAction to open the UncertaintyWizard for an exchange + """ + icon = qicons.edit + title = "Modify uncertainty" + exchanges: List[Any] + wizard: UncertaintyWizard + + def __init__(self, exchanges: Union[List[Any], Callable], parent: QtCore.QObject): + super().__init__(parent, exchanges=exchanges) + + def onTrigger(self, toggled): + self.wizard = UncertaintyWizard(self.exchanges[0], application.main_window) + self.wizard.show() diff --git a/activity_browser/actions/exchange/exchange_uncertainty_remove.py b/activity_browser/actions/exchange/exchange_uncertainty_remove.py new file mode 100644 index 000000000..83d1a982d --- /dev/null +++ b/activity_browser/actions/exchange/exchange_uncertainty_remove.py @@ -0,0 +1,24 @@ +from typing import Union, Callable, List, Any + +from PySide2 import QtCore + +from activity_browser import exchange_controller +from ..base import ABAction +from ...ui.icons import qicons +from ...bwutils import uncertainty + + +class ExchangeUncertaintyRemove(ABAction): + """ + ABAction to clear the uncertainty of one or multiple exchanges. + """ + icon = qicons.delete + title = "Remove uncertainty/-ies" + exchanges: List[Any] + + def __init__(self, exchanges: Union[List[Any], Callable], parent: QtCore.QObject): + super().__init__(parent, exchanges=exchanges) + + def onTrigger(self, toggled): + for exchange in self.exchanges: + exchange_controller.edit_exchange(exchange, uncertainty.EMPTY_UNCERTAINTY) diff --git a/activity_browser/actions/method/cf_amount_modify.py b/activity_browser/actions/method/cf_amount_modify.py new file mode 100644 index 000000000..45afbd1de --- /dev/null +++ b/activity_browser/actions/method/cf_amount_modify.py @@ -0,0 +1,39 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class CFAmountModify(ABAction): + """ + ABAction to modify the amount of a characterization factor within a method. Updates the CF-Tuple's second value + directly if there's no uncertainty dict. Otherwise, changes the "amount" from the uncertainty dict. + """ + icon = qicons.edit + title = "Modify amount" + method_name: tuple + char_factors: List[tuple] + amount: float + + def __init__(self, + method_name: Union[tuple, Callable], + char_factors: Union[List[tuple], Callable], + amount: Union[float, Callable], + parent: QtCore.QObject + ): + super().__init__(parent, method_name=method_name, char_factors=char_factors, amount=amount) + + def onTrigger(self, toggled): + char_factor = list(self.char_factors[0]) + if isinstance(char_factor[1], dict): + char_factor[1]['amount'] = self.amount + else: + char_factor[1] = self.amount + char_factor = tuple(char_factor) + + impact_category_controller.write_char_factors(self.method_name, [char_factor]) + + diff --git a/activity_browser/actions/method/cf_new.py b/activity_browser/actions/method/cf_new.py new file mode 100644 index 000000000..c04e2fcf4 --- /dev/null +++ b/activity_browser/actions/method/cf_new.py @@ -0,0 +1,59 @@ +from typing import Union, Callable, List + +import brightway2 as bw +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class CFNew(ABAction): + """ + ABAction to add a new characterization flow to a method through one or more elementary-flow keys. + """ + icon = qicons.add + title = "New characterization factor" + method_name: tuple + keys: List[tuple] + + def __init__(self, + method_name: Union[tuple, Callable], + keys: Union[List[tuple], Callable], + parent: QtCore.QObject + ): + super().__init__(parent, method_name=method_name, keys=keys) + + def onTrigger(self, toggled): + # load old cf's from the Method + old_cfs = bw.Method(self.method_name).load() + + # get the old_keys to be able to check for duplicates + if old_cfs: + old_keys, _ = list(zip(*old_cfs)) + # if no cfs, keys is an empty list + else: + old_keys = [] + + # use only the keys that don't already exist within the method + unique_keys = [key for key in self.keys if key not in old_keys] + + # if there are non-unique keys warn the user that these won't be added + if len(unique_keys) < len(self.keys): + QtWidgets.QMessageBox.warning( + application.main_window, + "Duplicate characterization factors", + "One or more of these elementary flows already exist within this method. Duplicate flows will not be " + "added" + ) + + # construct new characterization factors from the unique keys + new_cfs = [] + for key in unique_keys: + new_cfs.append((key, 0.0)) + + # return if there are none + if not new_cfs: return + + # otherwise instruct the ICController to write the new CF's to the method + impact_category_controller.write_char_factors(self.method_name, new_cfs, overwrite=False) diff --git a/activity_browser/actions/method/cf_remove.py b/activity_browser/actions/method/cf_remove.py new file mode 100644 index 000000000..a9202c9fb --- /dev/null +++ b/activity_browser/actions/method/cf_remove.py @@ -0,0 +1,42 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class CFRemove(ABAction): + """ + ABAction to remove one or more Characterization Factors from a method. First ask for confirmation and return if the + user cancels. Otherwise instruct the ImpactCategoryController to remove the selected Characterization Factors. + """ + icon = qicons.delete + title = "Remove CF('s)" + method_name: tuple + char_factors: List[tuple] + + def __init__(self, + method_name: Union[tuple, Callable], + char_factors: Union[List[tuple], Callable], + parent: QtCore.QObject + ): + super().__init__(parent, method_name=method_name, char_factors=char_factors) + + def onTrigger(self, toggled): + # ask the user whether they are sure to delete the calculation setup + warning = QtWidgets.QMessageBox.warning(application.main_window, + "Deleting Characterization Factors", + f"Are you sure you want to delete {len(self.char_factors)} CF('s)?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No + ) + + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: return + + # else remove the char_factors + impact_category_controller.delete_char_factors(self.method_name, self.char_factors) + + diff --git a/activity_browser/actions/method/cf_uncertainty_modify.py b/activity_browser/actions/method/cf_uncertainty_modify.py new file mode 100644 index 000000000..d567063e3 --- /dev/null +++ b/activity_browser/actions/method/cf_uncertainty_modify.py @@ -0,0 +1,46 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import application, impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons +from ...ui.wizards import UncertaintyWizard + + +class CFUncertaintyModify(ABAction): + """ + ABAction to launch the UncertaintyWizard for Characterization Factor and handles the output by writing the + uncertainty data using the ImpactCategoryController to the Characterization Factor in question. + """ + icon = qicons.edit + title = "Modify uncertainty" + method_name: tuple + char_factors: List[tuple] + wizard: UncertaintyWizard + + def __init__(self, + method_name: Union[tuple, Callable], + char_factors: Union[List[tuple], Callable], + parent: QtCore.QObject + ): + super().__init__(parent, method_name=method_name, char_factors=char_factors) + + def onTrigger(self, toggled): + self.wizard = UncertaintyWizard(self.char_factors[0], application.main_window) + self.wizard.complete.connect(self.wizardDone) + self.wizard.show() + + def wizardDone(self, cf: tuple, uncertainty: dict): + """Update the CF with new uncertainty information, possibly converting + the second item in the tuple to a dictionary without losing information. + """ + data = [*cf] + if isinstance(data[1], dict): + data[1].update(uncertainty) + else: + uncertainty["amount"] = data[1] + data[1] = uncertainty + + impact_category_controller.write_char_factors(self.method_name, [tuple(data)]) + diff --git a/activity_browser/actions/method/cf_uncertainty_remove.py b/activity_browser/actions/method/cf_uncertainty_remove.py new file mode 100644 index 000000000..604b9796b --- /dev/null +++ b/activity_browser/actions/method/cf_uncertainty_remove.py @@ -0,0 +1,42 @@ +from typing import Union, Callable, List + +from PySide2 import QtCore + +from activity_browser import impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class CFUncertaintyRemove(ABAction): + """ + ABAction to remove the uncertainty from one or multiple Characterization Factors. + """ + icon = qicons.clear + title = "Remove uncertainty" + method_name: tuple + char_factors: List[tuple] + + def __init__(self, + method_name: Union[tuple, Callable], + char_factors: Union[List[tuple], Callable], + parent: QtCore.QObject + ): + super().__init__(parent, method_name=method_name, char_factors=char_factors) + + def onTrigger(self, toggled): + # create a list of CF's of which the uncertainty dict is removed + cleaned_cfs = [] + for cf in self.char_factors: + # if there's no uncertainty dict, we may continue + if not isinstance(cf[1], dict): continue + + # else replace the uncertainty dict with the float found in the amount field of said dict + cleaned_cfs.append((cf[0], cf[1]['amount'])) + + # if the list is empty we don't need to call the controller + if not cleaned_cfs: return + + # else, instruct the controller to rewrite the characterization factors that had uncertainty dicts. + impact_category_controller.write_char_factors(self.method_name, cleaned_cfs) + + diff --git a/activity_browser/actions/method/method_delete.py b/activity_browser/actions/method/method_delete.py new file mode 100644 index 000000000..120b48ca0 --- /dev/null +++ b/activity_browser/actions/method/method_delete.py @@ -0,0 +1,51 @@ +from typing import Union, Callable, List, Optional + +import brightway2 as bw +from PySide2 import QtCore, QtWidgets + +from activity_browser import application, impact_category_controller +from ..base import ABAction +from ...ui.icons import qicons + + +class MethodDelete(ABAction): + """ + ABAction to remove one or multiple methods. First check whether the method is a node or leaf. If it's a node, also + include all underlying methods. Ask the user for confirmation, and return if canceled. Otherwise, remove all found + methods. + """ + icon = qicons.delete + title = "Delete Impact Category" + methods: List[tuple] + level: tuple + + def __init__(self, + methods: Union[List[tuple], Callable], + level: Optional[Union[tuple, Callable]], + parent: QtCore.QObject + ): + super().__init__(parent, methods=methods, level=level) + + def onTrigger(self, toggled): + # this action can handle only one selected method for now + selected_method = self.methods[0] + + # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for deletion + if self.level is not None and self.level != 'leaf': + methods = [bw.Method(method) for method in bw.methods if set(selected_method).issubset(method)] + else: + methods = [bw.Method(selected_method)] + + # warn the user about the pending deletion + warning = QtWidgets.QMessageBox.warning(application.main_window, + f"Deleting Method: {selected_method}", + "Are you sure you want to delete this method and possible underlying " + "methods?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No + ) + # return if the users cancels + if warning == QtWidgets.QMessageBox.No: return + + # instruct the controller to delete the selected methods + impact_category_controller.delete_methods(methods) diff --git a/activity_browser/actions/method/method_duplicate.py b/activity_browser/actions/method/method_duplicate.py new file mode 100644 index 000000000..9901d7ca0 --- /dev/null +++ b/activity_browser/actions/method/method_duplicate.py @@ -0,0 +1,50 @@ +from typing import Union, Callable, List, Optional + +import brightway2 as bw +from PySide2 import QtCore + +from activity_browser import application, impact_category_controller +from activity_browser.ui.widgets import TupleNameDialog +from ..base import ABAction +from ...ui.icons import qicons + + +class MethodDuplicate(ABAction): + """ + ABAction to duplicate a method, or node with all underlying methods to a new name specified by the user. + """ + icon = qicons.copy + title = "Duplicate Impact Category" + methods: List[tuple] + level: tuple + + def __init__(self, + methods: Union[List[tuple], Callable], + level: Optional[Union[tuple, Callable]], + parent: QtCore.QObject + ): + super().__init__(parent, methods=methods, level=level) + + def onTrigger(self, toggled): + # this action can handle only one selected method for now + selected_method = self.methods[0] + + # check whether we're dealing with a leaf or node. If it's a node, select all underlying methods for duplication + if self.level is not None and self.level != 'leaf': + methods = [bw.Method(method) for method in bw.methods if set(selected_method).issubset(method)] + else: + methods = [bw.Method(selected_method)] + + # retrieve the new name(s) from the user and return if canceled + dialog = TupleNameDialog.get_combined_name( + application.main_window, "Impact category name", "Combined name:", selected_method, " - Copy" + ) + if dialog.exec_() != TupleNameDialog.Accepted: return + + # for each method to be duplicated, construct a new location + location = dialog.result_tuple + new_names = [location + method.name[len(location):] for method in methods] + + # instruct the ImpactCategoryController to duplicate the methods to the new locations + impact_category_controller.duplicate_methods(methods, new_names) + diff --git a/activity_browser/actions/parameter/parameter_new.py b/activity_browser/actions/parameter/parameter_new.py new file mode 100644 index 000000000..5e897fe52 --- /dev/null +++ b/activity_browser/actions/parameter/parameter_new.py @@ -0,0 +1,200 @@ +from typing import Union, Callable, Optional, Tuple + +import brightway2 as bw +from activity_browser.bwutils import commontasks as bc +from PySide2 import QtCore, QtWidgets, QtGui + +from activity_browser import application +from activity_browser.controllers import parameter_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + +PARAMETER_STRINGS = ( + "Project: Available to all other parameters", + "Database: Available to Database and Activity parameters of the same database", + "Activity: Available to Activity and exchange parameters within the group", +) +PARAMETER_FIELDS = ( + ("name", "amount"), + ("name", "amount", "database"), + ("name", "amount"), +) + + +class ParameterNew(ABAction): + """ + ABAction to create a new Parameter. Opens the ParameterWizard, returns if the wizard is canceled. Else, checks + whether the name is valid, and then instructs the ParameterController to put the new parameter in the right group. + """ + icon = qicons.add + title = "New parameter..." + activity_key: Optional[Tuple[str, str]] + wizard: "ParameterWizard" + + def __init__(self, activity_key: Optional[Union[Tuple[str, str], Callable]], parent: QtCore.QObject): + super().__init__(parent, activity_key=activity_key) + + def onTrigger(self, toggled): + # instantiate the ParameterWizard + self.wizard = ParameterWizard(self.activity_key, application.main_window) + + # return if the wizard is canceled + if self.wizard.exec_() != self.wizard.Accepted: return + + # gather wizard variables + selection = self.wizard.selected + data = self.wizard.param_data + + # check whether the name is valid, otherwise return + name = data.get("name") + if name[0] in ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '#'): + error = QtWidgets.QErrorMessage() + error.showMessage("

Parameter names must not start with a digit, hyphen, or hash character

") + error.exec_() + return + + # select the right group and instruct the controller to create the parameter there + if selection == 0: + parameter_controller.add_parameter("project", data) + elif selection == 1: + db = data.pop("database") + parameter_controller.add_parameter(db, data) + elif selection == 2: + group = data.pop("group") + parameter_controller.add_parameter(group, data) + + +class ParameterWizard(QtWidgets.QWizard): + complete = QtCore.Signal(str, str, str) + + def __init__(self, key: tuple, parent=None): + super().__init__(parent) + + self.key = key + self.pages = ( + SelectParameterTypePage(self), + CompleteParameterPage(self), + ) + for i, p in enumerate(self.pages): + self.setPage(i, p) + + @property + def selected(self) -> int: + return self.pages[0].selected + + @property + def param_data(self) -> dict: + data = { + field: self.field(field) for field in PARAMETER_FIELDS[self.selected] + } + if self.selected == 2: + data["group"] = bc.build_activity_group_name(self.key) + data["database"] = self.key[0] + data["code"] = self.key[1] + return data + + +class SelectParameterTypePage(QtWidgets.QWizardPage): + def __init__(self, parent): + super().__init__(parent) + self.setTitle("Select the type of parameter to create.") + + self.key = parent.key + + layout = QtWidgets.QVBoxLayout() + box = QtWidgets.QGroupBox("Types:") + # Explicitly set the stylesheet to avoid parent classes overriding + box.setStyleSheet( + "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" + "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" + ) + box_layout = QtWidgets.QVBoxLayout() + self.button_group = QtWidgets.QButtonGroup() + self.button_group.setExclusive(True) + for i, s in enumerate(PARAMETER_STRINGS): + button = QtWidgets.QRadioButton(s) + self.button_group.addButton(button, i) + box_layout.addWidget(button) + # If we have a complete key, pre-select the activity parameter btn. + if all(self.key): + self.button_group.button(2).setChecked(True) + elif self.key[0] != "": + # default to database parameter is we have something. + self.button_group.button(2).setEnabled(False) + self.button_group.button(1).setChecked(True) + else: + # If we don't have a complete key, we can't create an activity parameter + self.button_group.button(2).setEnabled(False) + self.button_group.button(0).setChecked(True) + box.setLayout(box_layout) + layout.addWidget(box) + self.setLayout(layout) + + @property + def selected(self) -> int: + return self.button_group.checkedId() + + +class CompleteParameterPage(QtWidgets.QWizardPage): + def __init__(self, parent): + super().__init__(parent) + self.setTitle("Fill out required values for the parameter") + self.parent = parent + + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + box = QtWidgets.QGroupBox("Data:") + box.setStyleSheet( + "QGroupBox {border: 1px solid gray; border-radius: 5px; margin-top: 7px; margin-bottom: 7px; padding: 0px}" + "QGroupBox::title {top:-7 ex;left: 10px; subcontrol-origin: border}" + ) + grid = QtWidgets.QGridLayout() + box.setLayout(grid) + layout.addWidget(box) + + self.key = parent.key + + self.name_label = QtWidgets.QLabel("Name:") + self.name = QtWidgets.QLineEdit() + grid.addWidget(self.name_label, 0, 0) + grid.addWidget(self.name, 0, 1) + self.amount_label = QtWidgets.QLabel("Amount:") + self.amount = QtWidgets.QLineEdit() + locale = QtCore.QLocale(QtCore.QLocale.English) + locale.setNumberOptions(QtCore.QLocale.RejectGroupSeparator) + validator = QtGui.QDoubleValidator() + validator.setLocale(locale) + self.amount.setValidator(validator) + grid.addWidget(self.amount_label, 1, 0) + grid.addWidget(self.amount, 1, 1) + self.database_label = QtWidgets.QLabel("Database:") + self.database = QtWidgets.QComboBox() + grid.addWidget(self.database_label, 2, 0) + grid.addWidget(self.database, 2, 1) + + # Register fields for all possible values + self.registerField("name*", self.name) + self.registerField("amount", self.amount) + self.registerField("database", self.database, "currentText") + + def initializePage(self) -> None: + self.amount.setText("1.0") + if self.parent.selected == 0: + self.name.clear() + self.database.setHidden(True) + self.database_label.setHidden(True) + elif self.parent.selected == 1: + self.name.clear() + self.database.clear() + dbs = bw.databases.list + self.database.insertItems(0, dbs) + if self.key[0] in dbs: + self.database.setCurrentIndex( + dbs.index(self.key[0]) + ) + self.database.setHidden(False) + self.database_label.setHidden(False) + elif self.parent.selected == 2: + self.name.clear() + self.database.setHidden(True) + self.database_label.setHidden(True) diff --git a/activity_browser/actions/parameter/parameter_new_automatic.py b/activity_browser/actions/parameter/parameter_new_automatic.py new file mode 100644 index 000000000..459ec4027 --- /dev/null +++ b/activity_browser/actions/parameter/parameter_new_automatic.py @@ -0,0 +1,38 @@ +from typing import Union, Callable, List, Tuple + +import brightway2 as bw +from PySide2 import QtCore, QtWidgets, QtGui + +from activity_browser import application, parameter_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + +class ParameterNewAutomatic(ABAction): + """ + ABAction for the automatic creation of a new parameter. + + TODO: Remove this action as it is automatic and not user interaction, should be done through e.g. a signal but + TODO: will actually need to be reworked together with the parameters. + """ + icon = qicons.add + title = "New parameter..." + activity_keys: List[Tuple] + + def __init__(self, activity_keys: Union[List[Tuple], Callable], parent: QtCore.QObject): + super().__init__(parent, activity_keys=activity_keys) + + def onTrigger(self, toggled): + for key in self.activity_keys: + act = bw.get_activity(key) + if act.get("type", "process") != "process": + issue = f"Activity must be 'process' type, '{act.get('name')}' is type '{act.get('type')}'." + QtWidgets.QMessageBox.warning( + application.main_window, + "Not allowed", + issue, + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok + ) + continue + parameter_controller.auto_add_parameter(key) + diff --git a/activity_browser/actions/parameter/parameter_rename.py b/activity_browser/actions/parameter/parameter_rename.py new file mode 100644 index 000000000..e1275b9ac --- /dev/null +++ b/activity_browser/actions/parameter/parameter_rename.py @@ -0,0 +1,39 @@ +from typing import Union, Callable, Any + +from PySide2 import QtCore, QtWidgets + +from activity_browser import application +from activity_browser.controllers import parameter_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class ParameterRename(ABAction): + """ + ABAction to rename an existing parameter. Constructs a dialog for the user in which they choose the new name. If no + name is chosen, or the user cancels: return. Else, instruct the ParameterController to rename the parameter using + the given name. + """ + icon = qicons.edit + title = "Rename parameter..." + parameter: Any + + def __init__(self, parameter: Union[Any, Callable], parent: QtCore.QObject): + super().__init__(parent, parameter=parameter) + + def onTrigger(self, toggled): + new_name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Rename parameter", + f"Rename parameter '{self.parameter.name}' to:" + ) + + if not ok or not new_name: return + + try: + parameter_controller.rename_parameter(self.parameter, new_name) + except Exception as e: + QtWidgets.QMessageBox.warning( + application.main_window, "Could not save changes", str(e), + QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok + ) diff --git a/activity_browser/actions/plugin_wizard_open.py b/activity_browser/actions/plugin_wizard_open.py new file mode 100644 index 000000000..23fc2d763 --- /dev/null +++ b/activity_browser/actions/plugin_wizard_open.py @@ -0,0 +1,15 @@ +from activity_browser import application +from .base import ABAction +from ..ui.icons import qicons +from ..ui.wizards.plugins_manager_wizard import PluginsManagerWizard + + +class PluginWizardOpen(ABAction): + """ABAction to open the PluginWizard""" + icon = qicons.plugin + title = "Plugin manager..." + wizard: PluginsManagerWizard + + def onTrigger(self, toggled): + self.wizard = PluginsManagerWizard(application.main_window) + self.wizard.show() diff --git a/activity_browser/actions/project/project_delete.py b/activity_browser/actions/project/project_delete.py new file mode 100644 index 000000000..79e76acff --- /dev/null +++ b/activity_browser/actions/project/project_delete.py @@ -0,0 +1,53 @@ +import brightway2 as bw +from PySide2 import QtWidgets + +from activity_browser import application, ab_settings, log +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.ui.widgets import ProjectDeletionDialog +from activity_browser.controllers import project_controller + + +class ProjectDelete(ABAction): + """ + ABAction to delete the currently active project. Return if it's the startup project. + """ + icon = qicons.delete + title = "Delete" + tool_tip = "Delete the project" + + def onTrigger(self, toggled): + # get the current project + project_to_delete = bw.projects.current + + # if it's the startup project: reject deletion and inform user + if project_to_delete == ab_settings.startup_project: + QtWidgets.QMessageBox.information( + application.main_window, "Not possible", + "Can't delete the startup project. Please select another startup project in the settings first." + ) + return + + # open a delete dialog for the user to confirm, return if user rejects + delete_dialog = ProjectDeletionDialog.construct_project_deletion_dialog(application.main_window, + bw.projects.current) + if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: return + + # try to delete the project, delete directory if user specified so + try: + project_controller.delete_project(delete_dialog.deletion_warning_checked()) + # if an exception occurs, show warning box en log exception + except Exception as exception: + log.error(str(exception)) + QtWidgets.QMessageBox.warning( + application.main_window, + "An error occured", + "An error occured during project deletion. Please check the logs for more information." + ) + # if all goes well show info box that the project is deleted + else: + QtWidgets.QMessageBox.information( + application.main_window, + "Project deleted", + "Project succesfully deleted" + ) diff --git a/activity_browser/actions/project/project_duplicate.py b/activity_browser/actions/project/project_duplicate.py new file mode 100644 index 000000000..b29955cb4 --- /dev/null +++ b/activity_browser/actions/project/project_duplicate.py @@ -0,0 +1,36 @@ +import brightway2 as bw +from PySide2 import QtWidgets + +from activity_browser import application, project_controller +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons + + +class ProjectDuplicate(ABAction): + """ + ABAction to duplicate a project. Asks the user for a new name. Returns if no name is given, the user cancels, or + when the name is already in use by another project. Else, instructs the ProjectController to duplicate the current + project to the new name. + """ + icon = qicons.copy + title = "Duplicate" + tool_tip = "Duplicate the project" + + def onTrigger(self, toggled): + name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Duplicate current project", + f"Duplicate current project ({bw.projects.current}) to new name:" + " " * 10 + ) + + if not ok or not name: return + + if name in bw.projects: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible.", + "A project with this name already exists." + ) + return + + project_controller.duplicate_project(name) diff --git a/activity_browser/actions/project/project_new.py b/activity_browser/actions/project/project_new.py new file mode 100644 index 000000000..9633ac19d --- /dev/null +++ b/activity_browser/actions/project/project_new.py @@ -0,0 +1,37 @@ +import brightway2 as bw +from PySide2 import QtWidgets + +from activity_browser import application +from activity_browser.actions.base import ABAction +from activity_browser.ui.icons import qicons +from activity_browser.controllers import project_controller + + +class ProjectNew(ABAction): + """ + ABAction to create a new project. Asks the user for a new name. Returns if no name is given, the user cancels, or + when the name is already in use by another project. Otherwise, instructs the ProjectController to create a new + project with the given name, and switch to it. + """ + icon = qicons.add + title = "New" + tool_tip = "Make a new project" + + def onTrigger(self, toggled): + name, ok = QtWidgets.QInputDialog.getText( + application.main_window, + "Create new project", + "Name of new project:" + " " * 25 + ) + + if not ok or not name: return + + if name in bw.projects: + QtWidgets.QMessageBox.information( + application.main_window, + "Not possible.", + "A project with this name already exists." + ) + return + + project_controller.new_project(name) diff --git a/activity_browser/actions/settings_wizard_open.py b/activity_browser/actions/settings_wizard_open.py new file mode 100644 index 000000000..3a6204c9b --- /dev/null +++ b/activity_browser/actions/settings_wizard_open.py @@ -0,0 +1,15 @@ +from activity_browser import application +from .base import ABAction +from ..ui.icons import qicons +from ..ui.wizards.settings_wizard import SettingsWizard + + +class SettingsWizardOpen(ABAction): + """ABAction to open the SettingsWizard""" + icon = qicons.settings + title = "Settings..." + wizard: SettingsWizard + + def onTrigger(self, toggled): + self.wizard = SettingsWizard(application.main_window) + self.wizard.show() diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 56bd54fb6..a71103479 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -139,7 +139,7 @@ def evaluate_metadata(cls, metadata: dict, ignore_dbs: set): @classmethod def load_file(cls, filepath, whitelist=True, **kwargs): - """Similar to how the base class loads the data, but also perform + """Similar to how the base.py class loads the data, but also perform a number of evaluations on the metadata. Also, if given a 'relink' dictionary, perform relinking of exchanges. diff --git a/activity_browser/bwutils/metadata.py b/activity_browser/bwutils/metadata.py index 0cd6f9f1d..aa810a293 100644 --- a/activity_browser/bwutils/metadata.py +++ b/activity_browser/bwutils/metadata.py @@ -114,7 +114,7 @@ def update_metadata(self, key: tuple) -> None: except (UnknownObject, ActivityDataset.DoesNotExist): # Situation 1: activity has been deleted (metadata needs to be deleted) log.warning('Deleting activity from metadata:', key) - self.dataframe.drop(key, inplace=True) + self.dataframe.drop(key, inplace=True, errors="ignore") # print('Dimensions of the Metadata:', self.dataframe.shape) return diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index f7e0cdff3..43bd1f25c 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -11,7 +11,7 @@ class ABFileImporter(ABC): """ - Activity Browser abstract base class for scenario file imports + Activity Browser abstract base.py class for scenario file imports Contains a set of static methods for checking the file contents to conform to the desired standard. These include: diff --git a/activity_browser/controllers/__init__.py b/activity_browser/controllers/__init__.py index 9806fc5bc..a568d7b0d 100644 --- a/activity_browser/controllers/__init__.py +++ b/activity_browser/controllers/__init__.py @@ -1,10 +1,15 @@ # -*- coding: utf-8 -*- -from .activity import activity_controller -from .exchange import exchange_controller -from .database import database_controller -from .parameter import parameter_controller from .project import project_controller -from .impact_category import impact_category_controller +from .parameter import parameter_controller +from .database import database_controller from .calculation_setup import calculation_setup_controller -from .utilities import utilities_controller +from .activity import activity_controller from .plugin import plugin_controller +from .utilities import utilities_controller +from .exchange import exchange_controller + +# still to move +from .impact_category import impact_category_controller + + + diff --git a/activity_browser/controllers/activity.py b/activity_browser/controllers/activity.py index 1e6bcb88b..da8f89226 100644 --- a/activity_browser/controllers/activity.py +++ b/activity_browser/controllers/activity.py @@ -1,80 +1,61 @@ # -*- coding: utf-8 -*- -from typing import Iterator, Optional, Union +from typing import Iterator, Optional, Union, List import uuid import brightway2 as bw import pandas as pd from bw2data.backends.peewee.proxies import Activity -from PySide2.QtCore import QObject, Slot, Qt +from PySide2.QtCore import QObject, Slot from PySide2 import QtWidgets from activity_browser import project_settings, signals, application from activity_browser.bwutils import AB_metadata, commontasks as bc -from activity_browser.bwutils.strategies import relink_activity_exchanges from .parameter import ParameterController -from ..ui.widgets import ActivityLinkingDialog, ActivityLinkingResultsDialog, LocationLinkingDialog +from ..ui.widgets import LocationLinkingDialog class ActivityController(QObject): def __init__(self, parent=None): super().__init__(parent) signals.new_activity.connect(self.new_activity) - signals.delete_activity.connect(self.delete_activity) - signals.delete_activities.connect(self.delete_activity) - signals.duplicate_activity.connect(self.duplicate_activity) - signals.duplicate_activity_new_loc.connect(self.duplicate_activity_new_loc) - signals.duplicate_activities.connect(self.duplicate_activity) + signals.delete_activity.connect(self.delete_activities) + signals.delete_activities.connect(self.delete_activities) + signals.duplicate_activity.connect(self.duplicate_activities) + signals.duplicate_activities.connect(self.duplicate_activities) signals.duplicate_to_db_interface.connect(self.show_duplicate_to_db_interface) signals.duplicate_to_db_interface_multiple.connect(self.show_duplicate_to_db_interface) signals.activity_modified.connect(self.modify_activity) signals.duplicate_activity_to_db.connect(self.duplicate_activity_to_db) - signals.relink_activity.connect(self.relink_activity_exchange) - @Slot(str, name="createNewActivity") - def new_activity(self, database_name: str) -> None: - name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Create new technosphere activity", - "Please specify an activity name:" + " " * 10, + def new_activity(self, database_name: str, activity_name: str) -> None: + data = { + "name": activity_name, + "reference product": activity_name, + "unit": "unit", + "type": "process" + } + new_act = bw.Database(database_name).new_activity( + code=uuid.uuid4().hex, + **data ) - if ok and name: - data = { - "name": name, "reference product": name, "unit": "unit", - "type": "process" - } - new_act = bw.Database(database_name).new_activity( - code=uuid.uuid4().hex, - **data - ) - new_act.save() - production_exchange = new_act.new_exchange( - input=new_act, amount=1, type="production" - ) - production_exchange.save() - bw.databases.set_modified(database_name) - AB_metadata.update_metadata(new_act.key) - signals.database_changed.emit(database_name) - signals.databases_changed.emit() - signals.unsafe_open_activity_tab.emit(new_act.key) + new_act.save() - @Slot(tuple, name="deleteActivity") - @Slot(list, name="deleteActivities") - def delete_activity(self, data: Union[tuple, Iterator[tuple]]) -> None: - """Use the given data to delete one or more activities from brightway2.""" - activities = self._retrieve_activities(data) - text = ("One or more activities have downstream processes. " - "Deleting these activities will remove the exchange from the downstream processes, this can't be undone.\n\n" - "Are you sure you want to continue?") + production_exchange = new_act.new_exchange( + input=new_act, amount=1, type="production" + ) + production_exchange.save() + + bw.databases.set_modified(database_name) + AB_metadata.update_metadata(new_act.key) + + signals.database_changed.emit(database_name) + signals.databases_changed.emit() + signals.unsafe_open_activity_tab.emit(new_act.key) - if any(len(act.upstream()) > 0 for act in activities): - choice = QtWidgets.QMessageBox.warning(application.main_window, - "Activity/Activities has/have downstream processes", - text, - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - if choice == QtWidgets.QMessageBox.No: - return + def delete_activities(self, data: Union[tuple, Iterator[tuple]]) -> None: + """Use the given data to delete one or more activities from brightway2.""" + activities = self.get_activities(data) # Iterate through the activities and: # - Close any open activity tabs, @@ -108,13 +89,11 @@ def generate_copy_code(key: tuple) -> str: n = max((int(c.split('_copy')[1]) for c in copies)) return "{}_copy{}".format(code, n + 1) - @Slot(tuple, name="copyActivity") - @Slot(list, name="copyActivities") - def duplicate_activity(self, data: Union[tuple, Iterator[tuple]]) -> None: + def duplicate_activities(self, keys: List[tuple]) -> None: """Duplicates the selected activity in the same db, with a new BW code.""" # todo: add "copy of" (or similar) to name of activity for easy identification in new db # todo: some interface feedback so user knows the copy has succeeded - activities = self._retrieve_activities(data) + activities = self.get_activities(keys) for act in activities: new_code = self.generate_copy_code(act.key) @@ -137,178 +116,11 @@ def duplicate_activity(self, data: Union[tuple, Iterator[tuple]]) -> None: signals.database_changed.emit(db) signals.databases_changed.emit() - @Slot(tuple, name="copyActivityNewLoc") - def duplicate_activity_new_loc(self, old_key: tuple) -> None: - """Duplicates the selected activity in the same db, links to new location, with a new BW code. - - This function will try and link all exchanges in the same location as the production process - to a chosen location, if none is available for the given exchange, it will try to link to - RoW and then GLO, if those don't exist, the exchange is not altered. - - This def does the following: - - Read all databases in exchanges of activity into MetaDataStore - - Give user dialog to re-link location and potentially use alternatives - - Finds suitable activities with new location (and potentially alternative) - - Re-link exchanges to new (and potentially alternative) location - - Parameters - ---------- - old_key: the key of the activity to re-link to a different location - - Returns - ------- - """ - def find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) -> Optional[object]: - """Find a candidate to replace the exchange with.""" - current_db = exch.input[0] - if current_db == db_name: - db = dbs[current_db] - else: # if the exchange is not from the current database, also check the current - # (user may have added their own alternative dependents already) - db = pd.concat([dbs[current_db], dbs[db_name]]) - - if db.loc[db['key'] == exch.input]['location'].iloc[0] != old_location: - return # this exchange has a location we're not trying to re-link - - # get relevant data to match on - row = db.loc[db['key'] == exch.input] - name = row['name'].iloc[0] - prod = row['reference product'].iloc[0] - unit = row['unit'].iloc[0] - - # get candidates to match (must have same name, product and unit) - candidates = db.loc[(db['name'] == name) - & (db['reference product'] == prod) - & (db['unit'] == unit)] - if len(candidates) <= 1: - return # this activity does not exist in this database with another location (1 is self) - - # check candidates for new_location - candidate = candidates.loc[candidates['location'] == new_location] - if len(candidate) == 0 and not use_alternatives: - return # there is no candidate - elif len(candidate) > 1: - return # there is more than one candidate, we can't know what to use - elif len(candidate) == 0: - # there are no candidates, but we can try alternatives - for alt in alternatives: - candidate = candidates.loc[candidates['location'] == alt] - if len(candidate) == 1: - break # found an alternative in with this alternative location, stop looking - if len(candidate) != 1: - return # there are either no or multiple matches with alternative locations - return candidate - - act = self._retrieve_activities(old_key)[0] # we only take one activity but this function always returns list - db_name = act.key[0] - - # get list of dependent databases for activity and load to MetaDataStore - databases = [] - for exch in act.technosphere(): - databases.append(exch.input[0]) - if db_name not in databases: # add own database if it wasn't added already - databases.append(db_name) - - # load all dependent databases to MetaDataStore - dbs = {db: AB_metadata.get_database_metadata(db) for db in databases} - # get list of all unique locations in the dependent databases (sorted alphabetically) - locations = [] - for db in dbs.values(): - locations += db['location'].to_list() # add all locations to one list - locations = list(set(locations)) # reduce the list to only unique items - locations.sort() - - # get the location to relink - db = dbs[db_name] - old_location = db.loc[db['key'] == act.key]['location'].iloc[0] - - # trigger dialog with autocomplete-writeable-dropdown-list - options = (old_location, locations) - dialog = LocationLinkingDialog.relink_location(act['name'], options, application.main_window) - if dialog.exec_() != LocationLinkingDialog.Accepted: - # if the dialog accept button is not clicked, do nothing - return - - # read the data from the dialog - for old, new in dialog.relink.items(): - alternatives = [] - new_location = new - if dialog.use_rer.isChecked(): # RER - alternatives.append(dialog.use_rer.text()) - if dialog.use_ews.isChecked(): # Europe without Switzerland - alternatives.append(dialog.use_ews.text()) - if dialog.use_row.isChecked(): # RoW - alternatives.append(dialog.use_row.text()) - # the order we add alternatives is important, they are checked in this order! - if len(alternatives) > 0: - use_alternatives = True - else: - use_alternatives = False - - succesful_links = {} # dict of dicts, key of new exch : {new values} <-- see 'values' below - # in the future, 'alternatives' could be improved by making use of some location hierarchy. From that we could - # get things like if the new location is NL but there is no NL, but RER exists, we use that. However, for that - # we need some hierarchical structure to the location data, which may be available from ecoinvent, but we need - # to look for that. - - # get exchanges that we want to relink - for exch in act.technosphere(): - candidate = find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) - if candidate is None: - continue # no suitable candidate was found, try the next exchange - - # at this point, we have found 1 suitable candidate, whether that is new_location or alternative location - values = { - 'amount': exch.get('amount', False), - 'comment': exch.get('comment', False), - 'formula': exch.get('formula', False), - 'uncertainty': exch.get('uncertainty', False) - } - succesful_links[candidate['key'].iloc[0]] = values - - # now, create a new activity by copying the old one - new_code = self.generate_copy_code(act.key) - new_act = act.copy(new_code) - # update production exchanges - for exc in new_act.production(): - if exc.input.key == act.key: - exc.input = new_act - exc.save() - # update 'products' - for product in new_act.get('products', []): - if product.get('input') == act.key: - product.input = new_act.key - new_act.save() - # save the new location to the activity - self.modify_activity(new_act.key, 'location', new_location) - - # get exchanges that we want to delete - del_exch = [] # delete these exchanges - for exch in new_act.technosphere(): - candidate = find_candidate(dbs, exch, old_location, new_location, use_alternatives, alternatives) - if candidate is None: - continue # no suitable candidate was found, try the next exchange - del_exch.append(exch) - # delete exchanges with old locations - signals.exchanges_deleted.emit(del_exch) - - # add the new exchanges with all values carried over from last exch - signals.exchanges_add_w_values.emit(list(succesful_links.keys()), new_act.key, succesful_links) - - # update the MetaDataStore and open new activity - AB_metadata.update_metadata(new_act.key) - signals.safe_open_activity_tab.emit(new_act.key) - - # send signals to relevant locations - bw.databases.set_modified(db_name) - signals.database_changed.emit(db_name) - signals.databases_changed.emit() - @Slot(tuple, str, name="copyActivityToDbInterface") @Slot(list, str, name="copyActivitiesToDbInterface") def show_duplicate_to_db_interface(self, data: Union[tuple, Iterator[tuple]], db_name: Optional[str] = None) -> None: - activities = self._retrieve_activities(data) + activities = self.get_activities(data) origin_db = db_name or next(iter(activities)).get("database") available_target_dbs = list(project_settings.get_editable_databases()) @@ -365,35 +177,14 @@ def modify_activity(key: tuple, field: str, value: object) -> None: signals.database_changed.emit(key[0]) @staticmethod - def _retrieve_activities(data: Union[tuple, Iterator[tuple]]) -> Iterator[Activity]: + def get_activities(keys: Union[tuple, List[tuple]]) -> List[Activity]: """Given either a key-tuple or a list of key-tuples, return a list of activities. """ - return [bw.get_activity(data)] if isinstance(data, tuple) else [ - bw.get_activity(k) for k in data - ] - - @Slot(tuple, name="relinkActivityExchanges") - def relink_activity_exchange(self, key: tuple) -> None: - db = bw.Database(key[0]) - actvty = db.get(key[1]) - depends = db.find_dependents() - options = [(depend, bw.databases.list) for depend in depends] - dialog = ActivityLinkingDialog.relink_sqlite(actvty['name'], options, application.main_window) - relinking_results = {} - if dialog.exec_() == ActivityLinkingDialog.Accepted: - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - for old, new in dialog.relink.items(): - other = bw.Database(new) - failed, succeeded, examples = relink_activity_exchanges(actvty, old, other) - relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) - QtWidgets.QApplication.restoreOverrideCursor() - if failed > 0: - relinking_dialog = ActivityLinkingResultsDialog.present_relinking_results(application.main_window, relinking_results, examples) - relinking_dialog.exec_() - activity = relinking_dialog.open_activity() - signals.database_changed.emit(actvty['name']) - signals.databases_changed.emit() + if isinstance(keys, tuple): + return [bw.get_activity(keys)] + else: + return [bw.get_activity(k) for k in keys] activity_controller = ActivityController(application) diff --git a/activity_browser/controllers/calculation_setup.py b/activity_browser/controllers/calculation_setup.py index d0770ce6c..2bd909e5f 100644 --- a/activity_browser/controllers/calculation_setup.py +++ b/activity_browser/controllers/calculation_setup.py @@ -1,8 +1,5 @@ -import traceback - import brightway2 as bw -from PySide2.QtCore import QObject, Slot -from PySide2 import QtWidgets +from PySide2.QtCore import QObject from activity_browser import log, signals, application @@ -14,94 +11,27 @@ class CalculationSetupController(QObject): def __init__(self, parent=None): super().__init__(parent) - signals.new_calculation_setup.connect(self.new_calculation_setup) - signals.copy_calculation_setup.connect(self.copy_calculation_setup) - signals.rename_calculation_setup.connect(self.rename_calculation_setup) - signals.delete_calculation_setup.connect(self.delete_calculation_setup) - - @Slot(name="createCalculationSetup") - def new_calculation_setup(self) -> None: - name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Create new calculation setup", - "Name of new calculation setup:" + " " * 10 - ) - if ok and name: - if not self._can_use_cs_name(name): - return - bw.calculation_setups[name] = {'inv': [], 'ia': []} - signals.calculation_setup_selected.emit(name) - log.info("New calculation setup: {}".format(name)) - - @Slot(str, name="copyCalculationSetup") - def copy_calculation_setup(self, current: str) -> None: - new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Copy '{}'".format(current), - "Name of the copied calculation setup:" + " " * 10 - ) - if ok and new_name: - if not self._can_use_cs_name(new_name): - return - bw.calculation_setups[new_name] = bw.calculation_setups[current].copy() - signals.calculation_setup_selected.emit(new_name) - log.info("Copied calculation setup {} as {}".format(current, new_name)) - - @Slot(str, name="deleteCalculationSetup") - def delete_calculation_setup(self, name: str) -> None: - # ask the user whether they are sure to delete the calculation setup - warning = QtWidgets.QMessageBox.warning(application.main_window, - f"Deleting Calculation Setup: {name}", - "Are you sure you want to delete this calculation setup?", - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No) - - # return if the users cancels - if warning == QtWidgets.QMessageBox.No: return - - # otherwise try to delete the calculation setup - try: - del bw.calculation_setups[name] - signals.set_default_calculation_setup.emit() - # if an error occurs, notify the user and return - except Exception as e: - log.error(f"Deletion of calculation setup {name} failed with error {traceback.format_exc()}") - QtWidgets.QMessageBox.critical(application.main_window, - f"Deleting Calculation Setup: {name}", - "An error occured during the deletion of the calculation setup. Check the logs for more information", - QtWidgets.QMessageBox.Ok) - return - - # inform the user that the calculation setup has been deleted - log.info(f"Deleted calculation setup: {name}") - QtWidgets.QMessageBox.information(application.main_window, - f"Deleting Calculation Setup: {name}", - "Calculation setup was succesfully deleted.", - QtWidgets.QMessageBox.Ok) - - @Slot(str, name="renameCalculationSetup") - def rename_calculation_setup(self, current: str) -> None: - new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Rename '{}'".format(current), - "New name of this calculation setup:" + " " * 10 - ) - if ok and new_name: - if not self._can_use_cs_name(new_name): - return - bw.calculation_setups[new_name] = bw.calculation_setups[current].copy() - del bw.calculation_setups[current] - signals.calculation_setup_selected.emit(new_name) - log.info("Renamed calculation setup from {} to {}".format(current, new_name)) - - def _can_use_cs_name(self, new_name: str) -> bool: - if new_name in bw.calculation_setups.keys(): - QtWidgets.QMessageBox.warning( - application.main_window, "Not possible", - "A calculation setup with this name already exists." - ) - return False - return True + def new_calculation_setup(self, name) -> None: + bw.calculation_setups[name] = {'inv': [], 'ia': []} + signals.calculation_setup_selected.emit(name) + log.info("New calculation setup: {}".format(name)) + + def duplicate_calculation_setup(self, cs_name: str, new_name: str) -> None: + bw.calculation_setups[new_name] = bw.calculation_setups[cs_name].copy() + signals.calculation_setup_selected.emit(new_name) + log.info("Copied calculation setup {} as {}".format(cs_name, new_name)) + + def delete_calculation_setup(self, cs_name: str) -> None: + del bw.calculation_setups[cs_name] + signals.set_default_calculation_setup.emit() + signals.delete_calculation_setup.emit(cs_name) + log.info(f"Deleted calculation setup: {cs_name}") + + def rename_calculation_setup(self, cs_name: str, new_name: str) -> None: + bw.calculation_setups[new_name] = bw.calculation_setups[cs_name].copy() + del bw.calculation_setups[cs_name] + signals.calculation_setup_selected.emit(new_name) + log.info("Renamed calculation setup from {} to {}".format(cs_name, new_name)) calculation_setup_controller = CalculationSetupController(application) diff --git a/activity_browser/controllers/database.py b/activity_browser/controllers/database.py index b3721bcd4..3ef8189d4 100644 --- a/activity_browser/controllers/database.py +++ b/activity_browser/controllers/database.py @@ -2,50 +2,19 @@ import brightway2 as bw from bw2data.backends.peewee import sqlite3_lci_db from bw2data.parameters import Group -from PySide2 import QtWidgets -from PySide2.QtCore import QObject, Slot, Qt +from PySide2.QtCore import QObject, Slot from activity_browser import log, signals, project_settings, application from .project import ProjectController from ..bwutils import commontasks as bc -from ..bwutils.strategies import relink_exchanges_existing_db -from ..ui.widgets import ( - CopyDatabaseDialog, DatabaseLinkingDialog, DefaultBiosphereDialog, - BiosphereUpdater, DatabaseLinkingResultsDialog, EcoinventVersionDialog -) -from ..ui.wizards.db_export_wizard import DatabaseExportWizard -from ..ui.wizards.db_import_wizard import DatabaseImportWizard -from ..info import __ei_versions__ -from ..utils import sort_semantic_versions class DatabaseController(QObject): def __init__(self, parent=None): super().__init__(parent) - signals.import_database.connect(self.import_database_wizard) - signals.export_database.connect(self.export_database_wizard) - signals.update_biosphere.connect(self.update_biosphere) - signals.add_database.connect(self.add_database) - signals.delete_database.connect(self.delete_database) - signals.copy_database.connect(self.copy_database) - signals.install_default_data.connect(self.install_default_data) - signals.relink_database.connect(self.relink_database) - signals.project_selected.connect(self.ensure_sqlite_indices) - @Slot(name="openImportWizard") - def import_database_wizard(self) -> None: - """Start the database import wizard.""" - wizard = DatabaseImportWizard(application.main_window) - wizard.show() - - @Slot(name="openExportWizard") - def export_database_wizard(self) -> None: - wizard = DatabaseExportWizard(application.main_window) - wizard.show() - - @Slot(name="fixBrokenIndexes") def ensure_sqlite_indices(self): """ - fix for https://github.com/LCA-ActivityBrowser/activity-browser/issues/189 @@ -56,122 +25,30 @@ def ensure_sqlite_indices(self): log.info("creating missing sqlite indices") bw.Database(list(bw.databases)[-1])._add_indices() - @Slot(name="bw2Setup") - def install_default_data(self) -> None: + def new_database(self, name): + assert name not in bw.databases - # let user choose version - version_dialog = EcoinventVersionDialog(application.main_window) - if version_dialog.exec_() != EcoinventVersionDialog.Accepted: return - version = version_dialog.options.currentText() - - dialog = DefaultBiosphereDialog(version[:3], application.main_window) # only read Major/Minor part of version - dialog.show() - - @Slot(name="updateBiosphereDialog") - def update_biosphere(self) -> None: - """ Open a popup with progression bar and run through the different - functions for adding ecoinvent biosphere flows. - """ - # warn user of consequences of updating - warn_dialog = QtWidgets.QMessageBox.question( - application.main_window, "Update biosphere3?", - 'Newer versions of the biosphere database may not\n' - 'always be compatible with older ecoinvent versions.\n' - '\nUpdating the biosphere3 database cannot be undone!\n', - QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Abort, - QtWidgets.QMessageBox.Abort - ) - if warn_dialog is not QtWidgets.QMessageBox.Ok: return + bw.Database(name).register() + bw.Database(name).write({}) # write nothing to the database so we set a modified time - # let user choose version - version_dialog = EcoinventVersionDialog(application.main_window) - if version_dialog.exec_() != EcoinventVersionDialog.Accepted: return - version = version_dialog.options.currentText() + project_settings.add_db(name, False) + signals.databases_changed.emit() + signals.database_selected.emit(name) - # reduce biosphere update list up to the selected version - sorted_versions = sort_semantic_versions(__ei_versions__, highest_to_lowest=False) - ei_versions = sorted_versions[:sorted_versions.index(version) + 1] + def duplicate_database(self, from_db: str, to_db: str) -> None: + bw.Database(from_db).copy(to_db) + signals.databases_changed.emit() - # show updating dialog - dialog = BiosphereUpdater(ei_versions, application.main_window) - dialog.show() - - @Slot(name="addDatabase") - def add_database(self): - name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Create new database", - "Name of new database:" + " " * 25 - ) - - if ok and name: - if name not in bw.databases: - bw.Database(name).register() - bw.Database(name).write({}) # write nothing to the database so we set a modified time - project_settings.add_db(name, False) - signals.databases_changed.emit() - signals.database_selected.emit(name) - else: - QtWidgets.QMessageBox.information( - application.main_window, "Not possible", "A database with this name already exists." - ) - - @Slot(str, QObject, name="copyDatabaseAction") - def copy_database(self, name: str) -> None: - new_name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Copy {}".format(name), - "Name of new database:" + " " * 25) - if ok and new_name: - try: - # Attaching the created wizard to the class avoids the copying - # thread being prematurely destroyed. - copy_progress = CopyDatabaseDialog(application.main_window) - copy_progress.show() - copy_progress.begin_copy(name, new_name) - project_settings.add_db(new_name, project_settings.db_is_readonly(name)) - except ValueError as e: - QtWidgets.QMessageBox.information(application.main_window, "Not possible", str(e)) - - @Slot(str, name="deleteDatabase") def delete_database(self, name: str) -> None: - ok = QtWidgets.QMessageBox.question( - application.main_window, - "Delete database?", - ("Are you sure you want to delete database '{}'? It has {} activity datasets").format( - name, bc.count_database_records(name)) - ) - if ok == QtWidgets.QMessageBox.Yes: - project_settings.remove_db(name) - del bw.databases[name] - Group.delete().where(Group.name == name).execute() - ProjectController.change_project(bw.projects.current, reload=True) - signals.delete_database_confirmed.emit(name) - - @Slot(str, name="relinkDatabase") - def relink_database(self, db_name: str) -> None: - """Relink technosphere exchanges within the given database.""" - db = bw.Database(db_name) - depends = db.find_dependents() - options = [(depend, bw.databases.list) for depend in depends] - dialog = DatabaseLinkingDialog.relink_sqlite(db_name, options, application.main_window) - relinking_results = dict() - if dialog.exec_() == DatabaseLinkingDialog.Accepted: - # Now, start relinking. - QtWidgets.QApplication.setOverrideCursor(Qt.WaitCursor) - for old, new in dialog.relink.items(): - other = bw.Database(new) - failed, succeeded, examples = relink_exchanges_existing_db(db, old, other) - relinking_results[f"{old} --> {other.name}"] = (failed, succeeded) - QtWidgets.QApplication.restoreOverrideCursor() - if failed > 0: - QtWidgets.QApplication.restoreOverrideCursor() - relinking_dialog = DatabaseLinkingResultsDialog.present_relinking_results(application.main_window, relinking_results, examples) - relinking_dialog.exec_() - activity = relinking_dialog.open_activity() - QtWidgets.QApplication.restoreOverrideCursor() - signals.database_changed.emit(db_name) - signals.databases_changed.emit() + project_settings.remove_db(name) + del bw.databases[name] + Group.delete().where(Group.name == name).execute() + ProjectController.change_project(bw.projects.current, reload=True) + signals.delete_database_confirmed.emit(name) + + @staticmethod + def record_count(db_name: str) -> int: + return bc.count_database_records(db_name) database_controller = DatabaseController(application) diff --git a/activity_browser/controllers/exchange.py b/activity_browser/controllers/exchange.py index 0d083497a..2ab785bd5 100644 --- a/activity_browser/controllers/exchange.py +++ b/activity_browser/controllers/exchange.py @@ -6,22 +6,13 @@ from activity_browser import signals, application from activity_browser.bwutils import AB_metadata, commontasks as bc -from activity_browser.ui.wizards import UncertaintyWizard class ExchangeController(QObject): def __init__(self, parent=None): super().__init__(parent) - signals.exchanges_deleted.connect(self.delete_exchanges) - signals.exchanges_add.connect(self.add_exchanges) - signals.exchanges_add_w_values.connect(self.add_exchanges) - signals.exchange_modified.connect(self.modify_exchange) - signals.exchange_uncertainty_wizard.connect(self.edit_exchange_uncertainty) - signals.exchange_uncertainty_modified.connect(self.modify_exchange_uncertainty) - signals.exchange_pedigree_modified.connect(self.modify_exchange_pedigree) - - @Slot(list, tuple, name="addExchangesToKey") - def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple, new_values: dict = {}) -> None: + + def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple, new_values: dict = None) -> None: """ Add new exchanges. @@ -48,7 +39,7 @@ def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple, new_values: d else: exc['type'] = 'unknown' # add optional exchange values - if new_vals := new_values.get(key, {}): + if new_values and (new_vals := new_values.get(key, {})): for field_name, value in new_vals.items(): if value: exc[field_name] = value @@ -57,7 +48,6 @@ def add_exchanges(self, from_keys: Iterator[tuple], to_key: tuple, new_values: d AB_metadata.update_metadata(to_key) signals.database_changed.emit(to_key[0]) - @Slot(list, name="deleteExchanges") def delete_exchanges(self, exchanges: Iterator[ExchangeProxyBase]) -> None: db_changed = set() for exc in exchanges: @@ -67,50 +57,29 @@ def delete_exchanges(self, exchanges: Iterator[ExchangeProxyBase]) -> None: bw.databases.set_modified(db) signals.database_changed.emit(db) - @staticmethod - @Slot(object, str, object, name="editExchange") - def modify_exchange(exchange: ExchangeProxyBase, field: str, value) -> None: - # The formula field needs special handling. - if field == "formula": - if field in exchange and (value == "" or value is None): - # Remove formula entirely. - del exchange[field] - if "original_amount" in exchange: - # Restore the original amount, if possible - exchange["amount"] = exchange["original_amount"] - del exchange["original_amount"] - if value: - # At least set the formula, possibly also store the amount - if field not in exchange: - exchange["original_amount"] = exchange["amount"] + def edit_exchange(self, exchange: ExchangeProxyBase, data: dict): + recalculate_exchanges = False + + for field, value in data.items(): + if field == "amount": + exchange["amount"] = float(value) + + elif field == "formula": + edit_exchange_formula(exchange, value) + recalculate_exchanges = True + + elif field in {"loc", "scale", "shape", "minimum", "maximum"}: + edit_exchange_uncertainty(exchange, field, value) + + else: exchange[field] = value - else: - exchange[field] = value + exchange.save() bw.databases.set_modified(exchange["output"][0]) - if field == "formula": - # If a formula was set, removed or changed, recalculate exchanges - signals.exchange_formula_changed.emit(exchange["output"]) signals.database_changed.emit(exchange["output"][0]) - @Slot(object, name="runUncertaintyWizard") - def edit_exchange_uncertainty(self, exc: ExchangeProxyBase) -> None: - """Explicitly call the wizard here for altering the uncertainty.""" - wizard = UncertaintyWizard(exc, application.main_window) - wizard.show() - - @staticmethod - @Slot(object, object, name="modifyExchangeUncertainty") - def modify_exchange_uncertainty(exc: ExchangeProxyBase, unc_dict: dict) -> None: - unc_fields = {"loc", "scale", "shape", "minimum", "maximum"} - for k, v in unc_dict.items(): - if k in unc_fields and isinstance(v, str): - # Convert empty values into nan, accepted by stats_arrays - v = float("nan") if not v else float(v) - exc[k] = v - exc.save() - bw.databases.set_modified(exc["output"][0]) - signals.database_changed.emit(exc["output"][0]) + if recalculate_exchanges: + signals.exchange_formula_changed.emit(exchange["output"]) @staticmethod @Slot(object, object, name="modifyExchangePedigree") @@ -121,4 +90,26 @@ def modify_exchange_pedigree(exc: ExchangeProxyBase, pedigree: dict) -> None: signals.database_changed.emit(exc["output"][0]) +def edit_exchange_formula(exchange: ExchangeProxyBase, value): + if "formula" in exchange and (value == "" or value is None): + # Remove formula entirely. + del exchange["formula"] + if "original_amount" in exchange: + # Restore the original amount, if possible + exchange["amount"] = exchange["original_amount"] + del exchange["original_amount"] + if value: + # At least set the formula, possibly also store the amount + if "formula" not in exchange: + exchange["original_amount"] = exchange["amount"] + exchange["formula"] = value + + +def edit_exchange_uncertainty(exchange: ExchangeProxyBase, field: str, value): + if isinstance(value, str): + value = float("nan") if not value else float(value) + + exchange[field] = value + + exchange_controller = ExchangeController(application) diff --git a/activity_browser/controllers/impact_category.py b/activity_browser/controllers/impact_category.py index 1abd12096..377ae9469 100644 --- a/activity_browser/controllers/impact_category.py +++ b/activity_browser/controllers/impact_category.py @@ -1,128 +1,59 @@ # -*- coding: utf-8 -*- +from typing import List + import brightway2 as bw from PySide2.QtCore import QObject, Slot -from PySide2 import QtWidgets from activity_browser import log, signals, application -from activity_browser.ui.widgets import TupleNameDialog class ImpactCategoryController(QObject): def __init__(self, parent=None): super().__init__(parent) - signals.copy_method.connect(self.copy_method) - signals.delete_method.connect(self.delete_method) - signals.edit_method_cf.connect(self.modify_method_with_cf) - signals.remove_cf_uncertainties.connect(self.remove_uncertainty) - signals.add_cf_method.connect(self.add_method_to_cf) signals.delete_cf_method.connect(self.delete_method_from_cf) - @Slot(tuple, name="copyMethod") - def copy_method(self, method: tuple, level: str = None) -> None: - """Calls copy depending on the level, if level is 'leaf', or None, - then a single method is copied. Otherwise sets are used to identify - the appropriate methods""" - if level is not None and level != 'leaf': - methods = [bw.Method(mthd) for mthd in bw.methods if set(method).issubset(mthd)] - else: - methods = [bw.Method(method)] - dialog = TupleNameDialog.get_combined_name( - application.main_window, "Impact category name", "Combined name:", method, " - Copy" - ) - if dialog.exec_() != TupleNameDialog.Accepted: return - - new_name = dialog.result_tuple - for mthd in methods: - new_method = new_name + mthd.name[len(new_name):] - print('+', mthd) - if new_method in bw.methods: - warn = f"Impact Category with name '{new_method}' already exists!" - QtWidgets.QMessageBox.warning(application.main_window, "Copy failed", warn) - return - mthd.copy(new_method) - log.info("Copied method {} into {}".format(str(mthd.name), str(new_method))) + def duplicate_methods(self, methods: List[bw.Method], new_names: List[tuple]): + for method, new_name in zip(methods, new_names): + if new_name in bw.methods: + raise Exception("New method name already in use") + method.copy(new_name) + log.info(f"Copied method {method.name} into {new_name}") signals.new_method.emit() - @Slot(tuple, name="deleteMethod") - def delete_method(self, method_: tuple, level:str = None) -> None: + def delete_methods(self, methods: List[bw.Method]) -> None: """Call delete on the (first) selected method and present confirmation dialog.""" - if level is not None and level != 'leaf': - methods = [bw.Method(mthd) for mthd in bw.methods if set(method_).issubset(mthd)] - else: - methods = [bw.Method(method_)] - method = bw.Method(method_) - dialog = QtWidgets.QMessageBox() - dialog.setWindowTitle("Are you sure you want to delete this method?") - dialog.setText("You are about to PERMANENTLY delete the following Impact Category:\n(" - +", ".join(method.name)+ - ")\nAre you sure you want to continue?") - dialog.setIcon(QtWidgets.QMessageBox.Warning) - dialog.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) - dialog.setDefaultButton(QtWidgets.QMessageBox.No) - if dialog.exec_() == QtWidgets.QMessageBox.Yes: - for mthd in methods: - mthd.deregister() - log.info("Deleted method {}".format(str(mthd.name))) - signals.method_deleted.emit() - - @Slot(list, tuple, name="removeCFUncertainty") - def remove_uncertainty(self, removed: list, method: tuple) -> None: - """Remove all uncertainty information from the selected CFs. - - NOTE: Does not affect any selected CF that does not have uncertainty - information. - """ - def unset(cf: tuple) -> tuple: - data = [*cf] - data[1] = data[1].get("amount") - return tuple(data) + for method in methods: + method.deregister() + log.info(f"Deleted method {method.name}") + signals.method_deleted.emit() + def write_char_factors(self, method: tuple, char_factors: List[tuple], overwrite=True): method = bw.Method(method) - modified_cfs = ( - unset(cf) for cf in removed if isinstance(cf[1], dict) - ) cfs = method.load() - for cf in modified_cfs: - idx = next(i for i, c in enumerate(cfs) if c[0] == cf[0]) - cfs[idx] = cf - method.write(cfs) - signals.method_modified.emit(method.name) - @Slot(tuple, tuple, name="modifyMethodWithCf") - def modify_method_with_cf(self, cf: tuple, method: tuple) -> None: - """ Take the given CF tuple, add it to the method object stored in - `self.method` and call .write() & .process() to finalize. + for cf in char_factors: + index = next((i for i, c in enumerate(cfs) if c[0] == cf[0]), None) + + if index is not None and overwrite: + cfs[index] = cf + elif index is not None and not overwrite: + raise Exception("CF already exist in method, will not overwrite") + else: + cfs.append(cf) - NOTE: if the flow key matches one of the CFs in method, that CF - will be edited, if not, a new CF will be added to the method. - """ - method = bw.Method(method) - cfs = method.load() - idx = next((i for i, c in enumerate(cfs) if c[0] == cf[0]), None) - if idx is None: - cfs.append(cf) - else: - cfs[idx] = cf method.write(cfs) signals.method_modified.emit(method.name) - @Slot(tuple, tuple, name="addMethodToCF") - def add_method_to_cf(self, cf: tuple, method: tuple): + def delete_char_factors(self, method, char_factors: List[tuple]): + if not char_factors: return + method = bw.Method(method) cfs = method.load() - # fill in default values for a new cf row - cfdata = (cf, { - 'uncertainty type': 0, - 'loc': float('nan'), - 'scale': float('nan'), - 'shape': float('nan'), - 'minimum': float('nan'), - 'maximum': float('nan'), - 'negative': False, - 'amount': 0 - }) - cfs.append(cfdata) - method.write(cfs) + delete_keys, _ = list(zip(*char_factors)) + + new_cfs = [cf for cf in cfs if cf[0] not in delete_keys] + + method.write(new_cfs) signals.method_modified.emit(method.name) @Slot(tuple, tuple, name="deleteMethodFromCF") diff --git a/activity_browser/controllers/parameter.py b/activity_browser/controllers/parameter.py index 66f54bd08..c568380c8 100644 --- a/activity_browser/controllers/parameter.py +++ b/activity_browser/controllers/parameter.py @@ -1,61 +1,33 @@ # -*- coding: utf-8 -*- -from typing import List, Optional, Union +from typing import Union import brightway2 as bw -from bw2data.parameters import ActivityParameter, Group, ParameterBase -from PySide2.QtCore import QObject, Slot -from PySide2.QtWidgets import QInputDialog, QMessageBox, QErrorMessage +from bw2data.parameters import * +from PySide2.QtCore import QObject from activity_browser import signals, application from activity_browser.bwutils import commontasks as bc -from activity_browser.ui.wizards import ParameterWizard class ParameterController(QObject): def __init__(self, parent=None): super().__init__(parent) - signals.add_parameter.connect(self.add_parameter) - signals.add_activity_parameter.connect(self.auto_add_parameter) - signals.add_activity_parameters.connect(self.multiple_auto_parameters) - signals.parameter_modified.connect(self.modify_parameter) - signals.rename_parameter.connect(self.rename_parameter) - signals.delete_parameter.connect(self.delete_parameter) - signals.parameter_uncertainty_modified.connect(self.modify_parameter_uncertainty) - signals.parameter_pedigree_modified.connect(self.modify_parameter_pedigree) - signals.clear_activity_parameter.connect(self.clear_broken_activity_parameter) - @Slot(name="createSimpleParameterWizard") - @Slot(tuple, name="createParameterWizard") - def add_parameter(self, key: Optional[tuple] = None) -> None: - key = key or ("", "") - wizard = ParameterWizard(key, application.main_window) - - if wizard.exec_() == ParameterWizard.Accepted: - selection = wizard.selected - data = wizard.param_data - name = data.get("name") - if name[0] in ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '#'): - error = QErrorMessage() - error.showMessage("

Parameter names must not start with a digit, hyphen, or hash character

") - error.exec_() - return - amount = str(data.get("amount")) - p_type = "project" - if selection == 0: - bw.parameters.new_project_parameters([data]) - elif selection == 1: - db = data.pop("database") - bw.parameters.new_database_parameters([data], db) - p_type = "database ({})".format(db) - elif selection == 2: - group = data.pop("group") - bw.parameters.new_activity_parameters([data], group) - p_type = "activity ({})".format(group) - signals.added_parameter.emit(name, amount, p_type) + def add_parameter(self, group: str, data: dict) -> None: + name = data.get("name") + amount = str(data.get("amount")) + p_type = "project" + if group == "project": + bw.parameters.new_project_parameters([data]) + elif group in bw.databases: + bw.parameters.new_database_parameters([data], group) + p_type = f"database ({group})" + else: + bw.parameters.new_activity_parameters([data], group) + p_type = "activity ({})".format(group) + signals.added_parameter.emit(name, amount, p_type) - @staticmethod - @Slot(tuple, name="addActivityParameter") - def auto_add_parameter(key: tuple) -> None: + def auto_add_parameter(self, key: tuple) -> None: """ Given the activity key, generate a new row with data from the activity and immediately call `new_activity_parameters`. """ @@ -75,26 +47,6 @@ def auto_add_parameter(key: tuple) -> None: bw.parameters.new_activity_parameters([row], group) signals.parameters_changed.emit() - @Slot(list, name="addMultipleActivityParams") - def multiple_auto_parameters(self, keys: List[tuple]) -> None: - """Block the 'signals' object while iterating through the list of - keys, adding all of them as activity parameters. - """ - warning = "Activity must be 'process' type, '{}' is type '{}'." - signals.blockSignals(True) - for key in keys: - act = bw.get_activity(key) - if act.get("type", "process") != "process": - issue = warning.format(act.get("name"), act.get("type")) - QMessageBox.warning( - application.main_window, "Not allowed", issue, QMessageBox.Ok, QMessageBox.Ok - ) - continue - self.auto_add_parameter(key) - signals.blockSignals(False) - signals.parameters_changed.emit() - - @Slot(object, name="deleteParameter") def delete_parameter(self, parameter: ParameterBase) -> None: """ Remove the given parameter from the project. @@ -153,7 +105,6 @@ def delete_activity_parameter(key: tuple) -> None: bw.parameters.recalculate() signals.parameters_changed.emit() - @Slot(object, str, object, name="modifyParameter") def modify_parameter(self, param: ParameterBase, field: str, value: Union[str, float, list]) -> None: with bw.parameters.db.atomic() as transaction: @@ -172,48 +123,39 @@ def modify_parameter(self, param: ParameterBase, field: str, param.save() bw.parameters.recalculate() except Exception as e: - # Anything wrong? Roll the transaction back and throw up a - # warning message. + # Anything wrong? Roll the transaction back. transaction.rollback() - QMessageBox.warning( - application.main_window, "Could not save changes", str(e), - QMessageBox.Ok, QMessageBox.Ok - ) + raise e signals.parameters_changed.emit() - @Slot(object, str, name="renameParameter") - def rename_parameter(self, param: ParameterBase, group: str) -> None: - """Creates an input dialog where users can set a new name for the - given parameter. - - NOTE: Currently defaults to updating downstream formulas if needed, - by sub-classing the QInputDialog class it becomes possible to allow - users to decide if they want to update downstream parameters. + def rename_parameter(self, parameter: ParameterBase, new_name: str) -> None: """ - text = "Rename parameter '{}' to:".format(param.name) - new_name, ok = QInputDialog.getText( - application.main_window, "Rename parameter", text, - ) - if not ok or not new_name: - return - try: - old_name = param.name - if group == "project": - bw.parameters.rename_project_parameter(param, new_name, True) - elif group in bw.databases: - bw.parameters.rename_database_parameter(param, new_name, True) - else: - bw.parameters.rename_activity_parameter(param, new_name, True) - signals.parameters_changed.emit() - signals.parameter_renamed.emit(old_name, group, new_name) - except Exception as e: - QMessageBox.warning( - application.main_window, "Could not save changes", str(e), - QMessageBox.Ok, QMessageBox.Ok - ) + Rename a parameter + """ + old_name = parameter.name + group = self.get_parameter_group(parameter) + + if group == "project": + bw.parameters.rename_project_parameter(parameter, new_name, True) + elif group in bw.databases: + bw.parameters.rename_database_parameter(parameter, new_name, True) + else: + bw.parameters.rename_activity_parameter(parameter, new_name, True) + + signals.parameters_changed.emit() + signals.parameter_renamed.emit(old_name, group, new_name) + + @staticmethod + def get_parameter_group(parameter: ParameterBase) -> str: + if isinstance(parameter, ProjectParameter): + return "project" + elif isinstance(parameter, DatabaseParameter): + return parameter.database + elif isinstance(parameter, ActivityParameter): + return parameter.group + @staticmethod - @Slot(object, object, name="modifyParameterUncertainty") def modify_parameter_uncertainty(param: ParameterBase, uncertain: dict) -> None: unc_fields = {"loc", "scale", "shape", "minimum", "maximum"} for k, v in uncertain.items(): @@ -225,14 +167,12 @@ def modify_parameter_uncertainty(param: ParameterBase, uncertain: dict) -> None: signals.parameters_changed.emit() @staticmethod - @Slot(object, object, name="modifyParameterPedigree") def modify_parameter_pedigree(param: ParameterBase, pedigree: dict) -> None: param.data["pedigree"] = pedigree param.save() signals.parameters_changed.emit() @staticmethod - @Slot(str, str, str, name="deleteRemnantParameters") def clear_broken_activity_parameter(database: str, code: str, group: str) -> None: """Take the given information and attempt to remove all of the downstream parameter information. diff --git a/activity_browser/controllers/plugin.py b/activity_browser/controllers/plugin.py index 026b19c90..13e790f60 100644 --- a/activity_browser/controllers/plugin.py +++ b/activity_browser/controllers/plugin.py @@ -2,10 +2,9 @@ import importlib.util from pkgutil import iter_modules -from PySide2.QtCore import QObject, Slot +from PySide2.QtCore import QObject from activity_browser import log, signals, project_settings, ab_settings, application -from ..ui.wizards.plugins_manager_wizard import PluginsManagerWizard class PluginController(QObject): @@ -18,15 +17,9 @@ def __init__(self, parent=None): self.load_plugins() def connect_signals(self): - signals.manage_plugins.connect(self.manage_plugins_wizard) signals.project_selected.connect(self.reload_plugins) signals.plugin_selected.connect(self.add_plugin) - @Slot(name="openManagerWizard") - def manage_plugins_wizard(self) -> None: - self.wizard = PluginsManagerWizard(application.main_window) - self.wizard.show() - def load_plugins(self): names = self.discover_plugins() for name in names: diff --git a/activity_browser/controllers/project.py b/activity_browser/controllers/project.py index f2a257dc0..a4406e3d6 100644 --- a/activity_browser/controllers/project.py +++ b/activity_browser/controllers/project.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- import brightway2 as bw -from PySide2.QtCore import QObject, Slot -from PySide2 import QtWidgets +from PySide2.QtCore import QObject from activity_browser import log, signals, ab_settings, application from activity_browser.bwutils import commontasks as bc -from activity_browser.ui.widgets import ProjectDeletionDialog + class ProjectController(QObject): @@ -14,13 +13,7 @@ class ProjectController(QObject): """ def __init__(self, parent=None): super().__init__(parent) - signals.switch_bw2_dir_path.connect(self.switch_brightway2_dir_path) - signals.change_project.connect(self.change_project) - signals.new_project.connect(self.new_project) - signals.copy_project.connect(self.copy_project) - signals.delete_project.connect(self.delete_project) - @Slot(str, name="switchBwDirPath") def switch_brightway2_dir_path(self, dirpath: str) -> None: if bc.switch_brightway2_dir(dirpath): self.change_project(ab_settings.startup_project, reload=True) @@ -35,7 +28,6 @@ def load_settings(self) -> None: log.info('Brightway2 active project: {}'.format(bw.projects.current)) @staticmethod - @Slot(str, name="changeProject") def change_project(name: str = "default", reload: bool = False) -> None: """Change the project, this clears all tabs and metadata related to the current project. @@ -50,90 +42,31 @@ def change_project(name: str = "default", reload: bool = False) -> None: signals.project_selected.emit() log.info("Loaded project:", name) - @Slot(name="createProject") - def new_project(self, name=None): - if name is None: - name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Create new project", - "Name of new project:" + " " * 25 - ) - if not ok or not name: - return - - if name and name not in bw.projects: - bw.projects.set_current(name) - self.change_project(name, reload=True) - signals.projects_changed.emit() - elif name in bw.projects: - QtWidgets.QMessageBox.information( - application.main_window, "Not possible.", - "A project with this name already exists." - ) + def new_project(self, name: str): + bw.projects.set_current(name) + self.change_project(name, reload=True) + signals.projects_changed.emit() - @Slot(name="copyProject") - def copy_project(self): - name, ok = QtWidgets.QInputDialog.getText( - application.main_window, - "Copy current project", - "Copy current project ({}) to new name:".format(bw.projects.current) + " " * 10 - ) - if ok and name: - if name not in bw.projects: - bw.projects.copy_project(name, switch=True) - self.change_project(name) - signals.projects_changed.emit() - else: - QtWidgets.QMessageBox.information( - application.main_window, "Not possible.", - "A project with this name already exists." - ) + def duplicate_project(self, new_name: str): + bw.projects.copy_project(new_name, switch=True) + self.change_project(new_name) + signals.projects_changed.emit() - @Slot(name="deleteProject") - def delete_project(self): + def delete_project(self, delete_dir=False): """ Delete the currently active project. Reject if it's the last one. """ - project_to_delete: str = bw.projects.current - - # if it's the startup project: reject deletion and inform user - if project_to_delete == ab_settings.startup_project: - QtWidgets.QMessageBox.information( - application.main_window, "Not possible", "Can't delete the startup project. Please select another startup project in the settings first." - ) - return - - # open a delete dialog for the user to confirm, return if user rejects - delete_dialog = ProjectDeletionDialog.construct_project_deletion_dialog(application.main_window, bw.projects.current) - if delete_dialog.exec_() != ProjectDeletionDialog.Accepted: return + project_to_delete = bw.projects.current # change from the project to be deleted, to the startup project self.change_project(ab_settings.startup_project, reload=True) # try to delete the project, delete directory if user specified so - try: - bw.projects.delete_project( - project_to_delete, - delete_dir=delete_dialog.deletion_warning_checked() - ) - # if an exception occurs, show warning box en log exception - except Exception as exception: - log.error(str(exception)) - QtWidgets.QMessageBox.warning( - application.main_window, - "An error occured", - "An error occured during project deletion. Please check the logs for more information." - ) - # if all goes well show info box that the project is deleted - else: - QtWidgets.QMessageBox.information( - application.main_window, - "Project deleted", - "Project succesfully deleted" + bw.projects.delete_project( + project_to_delete, + delete_dir=delete_dir ) - # emit that the project list has changed because of the deletion, - # regardless of a possible exception (which may have deleted the project anyways) signals.projects_changed.emit() diff --git a/activity_browser/controllers/utilities.py b/activity_browser/controllers/utilities.py index f7dc8d9b7..476429674 100644 --- a/activity_browser/controllers/utilities.py +++ b/activity_browser/controllers/utilities.py @@ -3,7 +3,6 @@ from activity_browser import signals, application from activity_browser.bwutils import AB_metadata -from ..ui.wizards.settings_wizard import SettingsWizard class UtilitiesController(QObject): @@ -14,7 +13,6 @@ def __init__(self, parent=None): super().__init__(parent) signals.project_selected.connect(self.reset_metadata) signals.edit_activity.connect(self.print_convenience_information) - signals.edit_settings.connect(self.open_settings_wizard) @staticmethod @Slot(name="triggerMetadataReset") @@ -26,10 +24,5 @@ def reset_metadata() -> None: def print_convenience_information(db_name: str) -> None: AB_metadata.print_convenience_information(db_name) - @Slot(name="settingsWizard") - def open_settings_wizard(self) -> None: - wizard = SettingsWizard(application.main_window) - wizard.show() - -utilities_controller = UtilitiesController(application) \ No newline at end of file +utilities_controller = UtilitiesController(application) diff --git a/activity_browser/layouts/tabs/LCA_setup.py b/activity_browser/layouts/tabs/LCA_setup.py index 9656aefcc..8ce594279 100644 --- a/activity_browser/layouts/tabs/LCA_setup.py +++ b/activity_browser/layouts/tabs/LCA_setup.py @@ -7,6 +7,7 @@ from activity_browser import log, signals from .base import BaseRightTab +from ...actions import CSNew, CSDuplicate, CSDelete, CSRename from ...ui.icons import qicons from ...ui.style import horizontal_line, header, style_group_box from ...ui.widgets import ExcelReadDialog, ScenarioDatabaseDialog @@ -101,10 +102,21 @@ def __init__(self, parent=None): self.methods_table = CSMethodsTable(self) self.list_widget = CSList(self) - self.new_cs_button = QtWidgets.QPushButton(qicons.add, "New") - self.copy_cs_button = QtWidgets.QPushButton(qicons.copy, "Copy") - self.rename_cs_button = QtWidgets.QPushButton(qicons.edit, "Rename") - self.delete_cs_button = QtWidgets.QPushButton(qicons.delete, "Delete") + self.new_cs_button = QtWidgets.QToolButton(self) + self.new_cs_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.new_cs_button.setDefaultAction(CSNew(self)) + + self.duplicate_cs_button = QtWidgets.QToolButton(self) + self.duplicate_cs_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.duplicate_cs_button.setDefaultAction(CSDuplicate(self.list_widget.currentText, self)) + + self.delete_cs_button = QtWidgets.QToolButton(self) + self.delete_cs_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.delete_cs_button.setDefaultAction(CSDelete(self.list_widget.currentText, self)) + + self.rename_cs_button = QtWidgets.QToolButton(self) + self.rename_cs_button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.rename_cs_button.setDefaultAction(CSRename(self.list_widget.currentText, self)) self.calculate_button = QtWidgets.QPushButton(qicons.calculate, "Calculate") self.calculation_type = QtWidgets.QComboBox() @@ -114,7 +126,7 @@ def __init__(self, parent=None): name_row.addWidget(header('Calculation Setup:')) name_row.addWidget(self.list_widget) name_row.addWidget(self.new_cs_button) - name_row.addWidget(self.copy_cs_button) + name_row.addWidget(self.duplicate_cs_button) name_row.addWidget(self.rename_cs_button) name_row.addWidget(self.delete_cs_button) name_row.addStretch(1) @@ -163,19 +175,6 @@ def __init__(self, parent=None): def connect_signals(self): # Signals self.calculate_button.clicked.connect(self.start_calculation) - - self.new_cs_button.clicked.connect(signals.new_calculation_setup.emit) - self.copy_cs_button.clicked.connect( - lambda: signals.copy_calculation_setup.emit(self.list_widget.name) - ) - self.delete_cs_button.clicked.connect( - lambda x: signals.delete_calculation_setup.emit( - self.list_widget.name - )) - self.rename_cs_button.clicked.connect( - lambda x: signals.rename_calculation_setup.emit( - self.list_widget.name - )) signals.calculation_setup_changed.connect(self.save_cs_changes) self.calculation_type.currentIndexChanged.connect(self.select_calculation_type) @@ -232,7 +231,7 @@ def show_details(self, show: bool = True): # show/hide items from name_row self.rename_cs_button.setVisible(show) self.delete_cs_button.setVisible(show) - self.copy_cs_button.setVisible(show) + self.duplicate_cs_button.setVisible(show) self.list_widget.setVisible(show) # show/hide items from calc_row self.calculate_button.setVisible(show) diff --git a/activity_browser/layouts/tabs/parameters.py b/activity_browser/layouts/tabs/parameters.py index c89ceba10..d00fec778 100644 --- a/activity_browser/layouts/tabs/parameters.py +++ b/activity_browser/layouts/tabs/parameters.py @@ -3,6 +3,7 @@ import brightway2 as bw import pandas as pd +from PySide2 import QtWidgets, QtCore from PySide2.QtCore import Slot, QSize, Qt from PySide2.QtWidgets import ( QCheckBox, QFileDialog, QHBoxLayout, QMessageBox, QPushButton, QToolBar, @@ -12,6 +13,7 @@ from ...bwutils.manager import ParameterManager from ...signals import signals +from ...actions import ParameterNew from ...ui.icons import qicons from ...ui.style import header, horizontal_line from ...ui.tables import ( @@ -86,39 +88,34 @@ def get_table(self): class ABProjectParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) - self.newParameter = QPushButton(qicons.add, "New") + self.new_parameter_button = QtWidgets.QToolButton(self) + self.new_parameter_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.new_parameter_button.setDefaultAction(ParameterNew(("", ""), self)) + self.header = "Project:" self.table = ProjectParameterTable(self) - self.setLayout(self.create_layout(self.header, self.newParameter, self.table)) - self._connect_signal() - - def _connect_signal(self): - self.newParameter.clicked.connect( - lambda: signals.add_parameter.emit(None) - ) + self.setLayout(self.create_layout(self.header, self.new_parameter_button, self.table)) class ABDatabaseParameter(ABParameterTable): def __init__(self, parent=None): super().__init__(parent) self.header = "Database:" - self.newParameter = QPushButton(qicons.add, "New") - self.table = DataBaseParameterTable(self) - self.setLayout(self.create_layout(self.header, self.newParameter, self.table)) - self._connect_signal() + self.new_parameter_button = QtWidgets.QToolButton(self) + self.new_parameter_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.new_parameter_button.setDefaultAction(ParameterNew(("db", ""), self)) - def _connect_signal(self): - self.newParameter.clicked.connect( - lambda: signals.add_parameter.emit(("db", "")) - ) + self.table = DataBaseParameterTable(self) + + self.setLayout(self.create_layout(self.header, self.new_parameter_button, self.table)) def set_enabled(self, trigger): if not bw.databases: - self.newParameter.setEnabled(False) + self.new_parameter_button.setEnabled(False) else: - self.newParameter.setEnabled(True) + self.new_parameter_button.setEnabled(True) class ABActivityParameter(ABParameterTable): diff --git a/activity_browser/layouts/tabs/project_manager.py b/activity_browser/layouts/tabs/project_manager.py index 3bdf926a0..252d5f149 100644 --- a/activity_browser/layouts/tabs/project_manager.py +++ b/activity_browser/layouts/tabs/project_manager.py @@ -10,6 +10,7 @@ ActivitiesBiosphereTable, ) from ...signals import signals +from ...actions import DatabaseImport, DatabaseNew, DefaultInstall, ProjectNew, ProjectDuplicate, ProjectDelete class ProjectTab(QtWidgets.QWidget): def __init__(self, parent): @@ -67,22 +68,11 @@ def __init__(self): self.projects_list = ProjectListWidget() # Buttons - self.new_project_button = QtWidgets.QPushButton(qicons.add, "New") - self.new_project_button.setToolTip('Make a new project') - self.copy_project_button = QtWidgets.QPushButton(qicons.copy, "Copy") - self.copy_project_button.setToolTip('Copy the project') - self.delete_project_button = QtWidgets.QPushButton( - qicons.delete, "Delete" - ) - self.delete_project_button.setToolTip('Delete the project') + self.new_project_button = ProjectNew(self).button() + self.copy_project_button = ProjectDuplicate(self).button() + self.delete_project_button = ProjectDelete(self).button() self.construct_layout() - self.connect_signals() - - def connect_signals(self): - self.new_project_button.clicked.connect(signals.new_project.emit) - self.delete_project_button.clicked.connect(signals.delete_project.emit) - self.copy_project_button.clicked.connect(signals.copy_project.emit) def construct_layout(self): h_widget = QtWidgets.QWidget() @@ -120,21 +110,19 @@ def __init__(self, parent): ) # Buttons - self.add_default_data_button = QtWidgets.QPushButton( - qicons.import_db, "Add default data (biosphere flows and impact categories)" - ) - self.new_database_button = QtWidgets.QPushButton(qicons.add, "New") - self.new_database_button.setToolTip('Make a new database') - self.import_database_button = QtWidgets.QPushButton(qicons.import_db, "Import") - self.import_database_button.setToolTip('Import a new database') + self.add_default_data_button = QtWidgets.QToolButton(self) + self.add_default_data_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.add_default_data_button.setDefaultAction(DefaultInstall(self)) - self._construct_layout() - self._connect_signals() + self.new_database_button = QtWidgets.QToolButton(self) + self.new_database_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.new_database_button.setDefaultAction(DatabaseNew(self)) - def _connect_signals(self): - self.add_default_data_button.clicked.connect(signals.install_default_data.emit) - self.import_database_button.clicked.connect(signals.import_database.emit) - self.new_database_button.clicked.connect(signals.add_database.emit) + self.import_database_button = QtWidgets.QToolButton(self) + self.import_database_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.import_database_button.setDefaultAction(DatabaseImport(self)) + + self._construct_layout() def _construct_layout(self): header_widget = QtWidgets.QWidget() diff --git a/activity_browser/settings.py b/activity_browser/settings.py index 84c579d66..1df8a22df 100644 --- a/activity_browser/settings.py +++ b/activity_browser/settings.py @@ -177,7 +177,7 @@ def get_default_project_name() -> Optional[str]: class ProjectSettings(BaseSettings): """ Handles user settings which are specific to projects. Created initially to handle read-only/writable database status - Code based on ABSettings class, if more different types of settings are needed, could inherit from a base class + Code based on ABSettings class, if more different types of settings are needed, could inherit from a base.py class structure: singleton, loaded dependent on which project is selected. Persisted on disc, Stored in the BW2 projects data folder for each project diff --git a/activity_browser/ui/menu_bar.py b/activity_browser/ui/menu_bar.py index 80b888b4f..135e4fc49 100644 --- a/activity_browser/ui/menu_bar.py +++ b/activity_browser/ui/menu_bar.py @@ -6,6 +6,7 @@ from ..info import __version__ as ab_version from .icons import qicons from ..signals import signals +from ..actions import * class MenuBar(QtWidgets.QMenuBar): @@ -18,20 +19,11 @@ def __init__(self, window): self.tools_menu = QtWidgets.QMenu('&Tools', self.window) self.help_menu = QtWidgets.QMenu('&Help', self.window) - self.update_biosphere_action = QtWidgets.QAction( - window.style().standardIcon(QtWidgets.QStyle.SP_BrowserReload), - "&Update biosphere...", None - ) - self.export_db_action = QtWidgets.QAction( - self.window.style().standardIcon(QtWidgets.QStyle.SP_DriveHDIcon), - "&Export database...", None - ) - self.import_db_action = QtWidgets.QAction( - qicons.import_db, '&Import database...', None - ) - self.manage_plugins_action = QtWidgets.QAction( - qicons.plugin, '&Plugins...', None - ) + self.update_biosphere_action = BiosphereUpdate(self) + self.export_db_action = DatabaseExport(self) + self.import_db_action = DatabaseImport(self) + self.manage_plugins_action = PluginWizardOpen(self) + self.manage_settings_action = SettingsWizardOpen(self) self.addMenu(self.file_menu) self.addMenu(self.view_menu) @@ -47,21 +39,13 @@ def __init__(self, window): def connect_signals(self): signals.project_selected.connect(self.biosphere_exists) signals.databases_changed.connect(self.biosphere_exists) - self.update_biosphere_action.triggered.connect(signals.update_biosphere.emit) - self.export_db_action.triggered.connect(signals.export_database.emit) - self.import_db_action.triggered.connect(signals.import_database.emit) - self.manage_plugins_action.triggered.connect(signals.manage_plugins.emit) def setup_file_menu(self) -> None: """Build the menu for specific importing/export/updating actions.""" self.file_menu.addAction(self.import_db_action) self.file_menu.addAction(self.export_db_action) self.file_menu.addAction(self.update_biosphere_action) - self.file_menu.addAction( - qicons.settings, - '&Settings...', - signals.edit_settings.emit - ) + self.file_menu.addAction(self.manage_settings_action) def setup_view_menu(self) -> None: """Build the menu for viewing or hiding specific tabs""" diff --git a/activity_browser/ui/tables/activity.py b/activity_browser/ui/tables/activity.py index 84d5c7945..fab7c9b06 100644 --- a/activity_browser/ui/tables/activity.py +++ b/activity_browser/ui/tables/activity.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- +from typing import List + from PySide2 import QtWidgets from PySide2.QtCore import Slot +from activity_browser import actions from .delegates import * from .models import ( BaseExchangeModel, ProductExchangeModel, TechnosphereExchangeModel, @@ -9,7 +12,7 @@ ) from .views import ABDataFrameView from ..icons import qicons -from ...signals import signals +from ...actions import ExchangeNew class BaseExchangeTable(ABDataFrameView): @@ -24,21 +27,11 @@ def __init__(self, parent=None): QtWidgets.QSizePolicy.Maximum) ) - self.delete_exchange_action = QtWidgets.QAction( - qicons.delete, "Delete exchange(s)", None - ) - self.remove_formula_action = QtWidgets.QAction( - qicons.delete, "Clear formula(s)", None - ) - self.modify_uncertainty_action = QtWidgets.QAction( - qicons.edit, "Modify uncertainty", None - ) - self.remove_uncertainty_action = QtWidgets.QAction( - qicons.delete, "Remove uncertainty/-ies", None - ) - self.copy_exchanges_for_SDF_action = QtWidgets.QAction( - qicons.superstructure, "Exchanges for scenario difference file", None - ) + self.delete_exchange_action = actions.ExchangeDelete(self.selected_exchanges, self) + self.remove_formula_action = actions.ExchangeFormulaRemove(self.selected_exchanges, self) + self.modify_uncertainty_action = actions.ExchangeUncertaintyModify(self.selected_exchanges, self) + self.remove_uncertainty_action = actions.ExchangeUncertaintyRemove(self.selected_exchanges, self) + self.copy_exchanges_for_SDF_action = actions.ExchangeCopySDF(self.selected_exchanges, self) self.key = getattr(parent, "key", None) self.model = self.MODEL(self.key, self) self.downstream = False @@ -50,21 +43,6 @@ def _connect_signals(self): self.doubleClicked.connect( lambda: self.model.edit_cell(self.currentIndex()) ) - self.delete_exchange_action.triggered.connect( - lambda: self.model.delete_exchanges(self.selectedIndexes()) - ) - self.remove_formula_action.triggered.connect( - lambda: self.model.remove_formula(self.selectedIndexes()) - ) - self.modify_uncertainty_action.triggered.connect( - lambda: self.model.modify_uncertainty(self.currentIndex()) - ) - self.remove_uncertainty_action.triggered.connect( - lambda: self.model.remove_uncertainty(self.selectedIndexes()) - ) - self.copy_exchanges_for_SDF_action.triggered.connect( - lambda: self.model.copy_exchanges_for_SDF(self.selectedIndexes()) - ) self.model.updated.connect(self.update_proxy_model) self.model.updated.connect(self.custom_view_sizing) @@ -100,7 +78,7 @@ def dropEvent(self, event): source_table = event.source() keys = [source_table.get_key(i) for i in source_table.selectedIndexes()] event.accept() - signals.exchanges_add.emit(keys, self.key) + ExchangeNew(keys, self.key, self).trigger() def get_usable_parameters(self): return self.model.get_usable_parameters() @@ -108,6 +86,9 @@ def get_usable_parameters(self): def get_interpreter(self): return self.model.get_interpreter() + def selected_exchanges(self) -> List[any]: + return [self.model.get_exchange(index) for index in self.selectedIndexes()] + class ProductExchangeTable(BaseExchangeTable): MODEL = ProductExchangeModel diff --git a/activity_browser/ui/tables/delegates/formula.py b/activity_browser/ui/tables/delegates/formula.py index 3f87eeefe..0742e01cd 100644 --- a/activity_browser/ui/tables/delegates/formula.py +++ b/activity_browser/ui/tables/delegates/formula.py @@ -6,7 +6,7 @@ from PySide2.QtCore import Signal, Slot from activity_browser.signals import signals -from activity_browser.ui.icons import qicons +from ....actions import ParameterNew class CalculatorButtons(QtWidgets.QWidget): @@ -90,12 +90,10 @@ def __init__(self, parent=None, flags=QtCore.Qt.Window): completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) self.text_field.setCompleter(completer) self.parameters.doubleClicked.connect(self.append_parameter_name) - self.new_parameter = QtWidgets.QPushButton( - qicons.add, "New parameter", self - ) - self.new_parameter.clicked.connect( - lambda: signals.add_parameter.emit(self.key) - ) + + self.new_parameter_button = QtWidgets.QToolButton(self) + self.new_parameter_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.new_parameter_button.setDefaultAction(ParameterNew(self.get_key, self)) self.calculator = CalculatorButtons(self) self.calculator.button_press.connect(self.text_field.insert) @@ -105,7 +103,7 @@ def __init__(self, parent=None, flags=QtCore.Qt.Window): grid.addWidget(self.buttons, 5, 0, 1, 1) grid.addWidget(self.calculator, 0, 1, 5, 1) grid.addWidget(self.parameters, 0, 2, 5, 1) - grid.addWidget(self.new_parameter, 5, 2, 1, 1) + grid.addWidget(self.new_parameter_button, 5, 2, 1, 1) self.buttons.accepted.connect(self.accept) self.buttons.rejected.connect(self.reject) @@ -149,6 +147,9 @@ def insert_key(self, key: tuple) -> None: """ self.key = key + def get_key(self) -> tuple: + return self.key + @property def formula(self) -> str: """ Look into the text_field and return the formula. diff --git a/activity_browser/ui/tables/delegates/uncertainty.py b/activity_browser/ui/tables/delegates/uncertainty.py index 63ab866a5..f732f2058 100644 --- a/activity_browser/ui/tables/delegates/uncertainty.py +++ b/activity_browser/ui/tables/delegates/uncertainty.py @@ -29,7 +29,7 @@ def displayText(self, value, locale): def createEditor(self, parent, option, index): """Simply use the wizard for updating uncertainties. Send a signal. """ - signals.set_uncertainty.emit(index) + self.parent().modify_uncertainty_action.trigger() def setEditorData(self, editor: QtWidgets.QComboBox, index: QtCore.QModelIndex): """Simply use the wizard for updating uncertainties. diff --git a/activity_browser/ui/tables/impact_categories.py b/activity_browser/ui/tables/impact_categories.py index 3091cb587..dcb4aaa44 100644 --- a/activity_browser/ui/tables/impact_categories.py +++ b/activity_browser/ui/tables/impact_categories.py @@ -4,6 +4,7 @@ from PySide2 import QtWidgets from PySide2.QtCore import QModelIndex, Slot +from activity_browser import actions from ...signals import signals from ..icons import qicons from .views import ABDictTreeView, ABFilterableDataFrameView, ABDataFrameView @@ -31,6 +32,9 @@ def __init__(self, parent=None): signals.new_method.connect(self.sync) signals.method_deleted.connect(self.sync) + self.duplicate_method_action = actions.MethodDuplicate(self.selected_methods, None, self) + self.delete_method_action = actions.MethodDelete(self.selected_methods, None, self) + def selected_methods(self) -> Iterable: """Returns a generator which yields the 'method' for each row.""" return (self.model.get_method(p) for p in self.selectedIndexes()) @@ -49,15 +53,10 @@ def custom_view_sizing(self) -> None: def contextMenuEvent(self, event) -> None: if self.indexAt(event.pos()).row() == -1: return + menu = QtWidgets.QMenu(self) - menu.addAction( - qicons.copy, "Duplicate Impact Category", - lambda: self.model.copy_method(self.currentIndex()) - ) - menu.addAction( - qicons.delete, "Delete Impact Category", - lambda: self.model.delete_method(self.currentIndex()) - ) + menu.addAction(self.duplicate_method_action) + menu.addAction(self.delete_method_action) menu.addAction( qicons.edit, "Inspect Impact Category", lambda: signals.method_selected.emit(self.model.get_method(self.currentIndex())) @@ -104,6 +103,9 @@ def __init__(self, parent=None): self.model.sync() self.setColumnHidden(self.model.method_col, True) + self.duplicate_method_action = actions.MethodDuplicate(self.selected_methods, self.tree_level, self) + self.delete_method_action = actions.MethodDelete(self.selected_methods, self.tree_level, self) + def _connect_signals(self): super()._connect_signals() self.doubleClicked.connect(self.method_selected) @@ -163,12 +165,8 @@ def contextMenuEvent(self, event) -> None: menu.addSeparator() - menu.addAction(qicons.copy, "Duplicate Impact Category", - lambda: self.copy_method() - ) - menu.addAction(qicons.delete, "Delete Impact Category", - lambda: self.delete_method() - ) + menu.addAction(self.duplicate_method_action) + menu.addAction(self.delete_method_action) menu.exec_(event.globalPos()) @@ -216,16 +214,6 @@ def find_levels(self, level=None) -> list: parent = parent.parent() return levels[::-1] - @Slot(name="copyMethod") - def copy_method(self) -> None: - """Call copy on the (first) selected method and present rename dialog.""" - self.model.copy_method(self.tree_level()) - - @Slot(name="deleteMethod") - def delete_method(self) -> None: - """Call copy on the (first) selected method and present rename dialog.""" - self.model.delete_method(self.tree_level()) - def expanded_list(self): it = self.model.iterator(None) expanded_items = [] @@ -265,17 +253,29 @@ def __init__(self, parent=None): self.read_only = True self.setAcceptDrops(not self.read_only) - signals.set_uncertainty.connect(self.modify_uncertainty) - signals.cf_changed.connect(self.cell_edited) + self.remove_cf_action = actions.CFRemove(self.method_name, self.selected_cfs, self) + self.modify_uncertainty_action = actions.CFUncertaintyModify(self.method_name, self.selected_cfs, self) + self.remove_uncertainty_action = actions.CFUncertaintyRemove(self.method_name, self.selected_cfs, self) + + self.model.dataChanged.connect(self.cell_edited) + + def method_name(self): + return self.model.method.name + + def selected_cfs(self): + return [self.model.get_cf(i) for i in self.selectedIndexes()] def cell_edited(self) -> None: """Store the edit made to the table in the underlying data.""" - if len(self.selectedIndexes()) == 0: - return - col = self.model.proxy_to_source(self.selectedIndexes()[0]).column() - if col in [2]: + if len(self.selectedIndexes()) == 0: return + + cell = self.selectedIndexes()[0] + column = cell.column() + + if column in [2]: # if the column changed is 2 (Amount) --> This is a list in case of future editable columns - self.model.modify_cf(self.selectedIndexes()[0]) + new_amount = self.model.get_value(cell) + actions.CFAmountModify(self.method_name, self.selected_cfs, new_amount, self).trigger() @Slot(name="resizeView") def custom_view_sizing(self) -> None: @@ -300,28 +300,15 @@ def contextMenuEvent(self, event) -> None: if self.indexAt(event.pos()).row() == -1: return menu = QtWidgets.QMenu(self) - edit = menu.addAction(qicons.edit, "Modify uncertainty", self.modify_uncertainty) - edit.setEnabled(not self.read_only) + menu.addAction(self.modify_uncertainty_action) + self.modify_uncertainty_action.setEnabled(not self.read_only) menu.addSeparator() - remove = menu.addAction(qicons.clear, "Remove uncertainty", self.remove_uncertainty) - remove.setEnabled(not self.read_only) - delete = menu.addAction(qicons.delete, "Delete", self.delete_cf) - delete.setEnabled(not self.read_only) + menu.addAction(self.remove_uncertainty_action) + self.remove_uncertainty_action.setEnabled(not self.read_only) + menu.addAction(self.remove_cf_action) + self.remove_cf_action.setEnabled(not self.read_only) menu.exec_(event.globalPos()) - @Slot(name="modifyCFUncertainty") - def modify_uncertainty(self, index) -> None: - if index.internalId() == self.currentIndex().internalId(): - self.model.modify_uncertainty(self.currentIndex()) - - @Slot(name="removeCFUncertainty") - def remove_uncertainty(self) -> None: - self.model.remove_uncertainty(self.selectedIndexes()) - - @Slot(name="deleteCF") - def delete_cf(self) -> None: - self.model.delete_cf(self.selectedIndexes()) - def dragMoveEvent(self, event) -> None: """ Check if drops are allowed when dragging something over. """ @@ -337,5 +324,5 @@ def dropEvent(self, event): if not isinstance(source_table, ActivitiesBiosphereTable): return event.accept() - signals.add_cf_method.emit(keys[0], self.model.method.name) + actions.CFNew(self.method_name, keys, self).trigger() # TODO: Resize the view if the table did not already take up the full height. diff --git a/activity_browser/ui/tables/inventory.py b/activity_browser/ui/tables/inventory.py index 30c9e6359..d63e6c0b5 100644 --- a/activity_browser/ui/tables/inventory.py +++ b/activity_browser/ui/tables/inventory.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- +from typing import List + from PySide2 import QtWidgets, QtCore from PySide2.QtCore import Slot +from activity_browser import application +from activity_browser.actions import * from ...settings import project_settings from ...signals import signals from ..icons import qicons @@ -27,12 +31,12 @@ def __init__(self, parent=None): QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum )) - self.relink_action = QtWidgets.QAction( - qicons.edit, "Relink the database", None - ) - self.new_activity_action =QtWidgets.QAction( - qicons.add, "Add new activity", None - ) + + self.relink_action = DatabaseRelink(self.current_database, self) + self.new_activity_action = ActivityNew(self.current_database, self) + self.delete_db_action = DatabaseDelete(self.current_database, self) + self.duplicate_db_action = DatabaseDuplicate(self.current_database, self) + self.model = DatabasesModel(parent=self) self._connect_signals() @@ -40,12 +44,6 @@ def _connect_signals(self): self.doubleClicked.connect( lambda p: signals.database_selected.emit(self.model.get_db_name(p)) ) - self.relink_action.triggered.connect( - lambda: signals.relink_database.emit(self.selected_db_name) - ) - self.new_activity_action.triggered.connect( - lambda: signals.new_activity.emit(self.selected_db_name) - ) self.model.updated.connect(self.update_proxy_model) self.model.updated.connect(self.custom_view_sizing) @@ -54,15 +52,9 @@ def contextMenuEvent(self, event) -> None: return menu = QtWidgets.QMenu(self) - menu.addAction( - qicons.delete, "Delete database", - lambda: signals.delete_database.emit(self.selected_db_name) - ) + menu.addAction(self.delete_db_action) menu.addAction(self.relink_action) - menu.addAction( - qicons.duplicate_database, "Copy database", - lambda: signals.copy_database.emit(self.selected_db_name) - ) + menu.addAction(self.duplicate_db_action) menu.addAction(self.new_activity_action) proxy = self.indexAt(event.pos()) if proxy.isValid(): @@ -91,8 +83,7 @@ def mousePressEvent(self, e): self.model.sync() super().mousePressEvent(e) - @property - def selected_db_name(self) -> str: + def current_database(self) -> str: """ Return the database name of the user-selected index. """ return self.model.get_db_name(self.currentIndex()) @@ -107,42 +98,21 @@ def __init__(self, parent=None): self.setDragEnabled(True) self.setDragDropMode(QtWidgets.QTableView.DragOnly) - # contextmenu items - self.open_activity_action = QtWidgets.QAction( - qicons.right, 'Open ***', None - ) - self.open_activity_graph_action = QtWidgets.QAction( - qicons.graph_explorer, 'Open *** in Graph Explorer', None - ) - self.new_activity_action = QtWidgets.QAction( - qicons.add, 'Add new activity', None - ) - self.duplicate_activity_action = QtWidgets.QAction( - qicons.copy, 'Duplicate ***', None - ) - self.duplicate_activity_new_loc_action = QtWidgets.QAction( - qicons.copy, 'Duplicate activity to new location', None - ) - self.duplicate_activity_new_loc_action.setToolTip( - 'Duplicate this activity to another location.\n' - 'Link the exchanges to a new location if it is available.') # only for 1 activity - self.delete_activity_action = QtWidgets.QAction( - qicons.delete, 'Delete ***', None - ) - self.relink_activity_exch_action = QtWidgets.QAction( - qicons.edit, 'Relink the activity exchanges' - ) - self.duplicate_other_db_action = QtWidgets.QAction( - qicons.duplicate_to_other_database, 'Duplicate to other database' - ) - + # context-menu items + self.open_activity_action = ActivityOpen(self.selected_keys, self) + self.open_activity_graph_action = ActivityGraph(self.selected_keys, self) + self.new_activity_action = ActivityNew(self.current_database, self) + self.duplicate_activity_action = ActivityDuplicate(self.selected_keys, self) + self.duplicate_activity_new_loc_action = ActivityDuplicateToLoc(lambda: self.selected_keys()[0], self) + self.delete_activity_action = ActivityDelete(self.selected_keys, self) + self.relink_activity_exch_action = ActivityRelink(self.selected_keys, self) + self.duplicate_other_db_action = ActivityDuplicateToDB(self.selected_keys, self) self.copy_exchanges_for_SDF_action = QtWidgets.QAction( qicons.superstructure, 'Exchanges for scenario difference file', None ) self.connect_signals() - @property - def database_name(self) -> str: + def current_database(self) -> str: return self.model.database_name @property @@ -207,18 +177,11 @@ def contextMenuEvent(self, event) -> None: def connect_signals(self): signals.database_read_only_changed.connect(self.update_activity_table_read_only) - self.open_activity_action.triggered.connect(self.open_activity_tabs) - self.open_activity_graph_action.triggered.connect(self.open_graph_explorer) - self.new_activity_action.triggered.connect( - lambda: signals.new_activity.emit(self.database_name) - ) - self.duplicate_activity_action.triggered.connect(self.duplicate_activities) - self.duplicate_activity_new_loc_action.triggered.connect(self.duplicate_activity_to_new_loc) - self.delete_activity_action.triggered.connect(self.delete_activities) - self.relink_activity_exch_action.triggered.connect(self.relink_activity_exchanges) - self.duplicate_other_db_action.triggered.connect(self.duplicate_activities_to_db) + self.copy_exchanges_for_SDF_action.triggered.connect(self.copy_exchanges_for_SDF) - self.doubleClicked.connect(self.open_activity_tab) + + self.doubleClicked.connect(self.open_activity_action.trigger) + self.model.updated.connect(self.update_proxy_model) self.model.updated.connect(self.custom_view_sizing) self.model.updated.connect(self.set_context_menu_policy) @@ -227,50 +190,14 @@ def connect_signals(self): def get_key(self, proxy: QtCore.QModelIndex) -> tuple: return self.model.get_key(proxy) + def selected_keys(self) -> List[tuple]: + return [self.model.get_key(index) for index in self.selectedIndexes()] + def update_filter_settings(self) -> None: # Write the column indices so only those columns get filter button if isinstance(self.model.filterable_columns, dict): self.header.column_indices = list(self.model.filterable_columns.values()) - @Slot(QtCore.QModelIndex, name="openActivityTab") - def open_activity_tab(self, proxy: QtCore.QModelIndex) -> None: - key = self.model.get_key(proxy) - signals.safe_open_activity_tab.emit(key) - signals.add_activity_to_history.emit(key) - - @Slot(name="openActivityTabs") - def open_activity_tabs(self) -> None: - for key in (self.model.get_key(p) for p in self.selectedIndexes()): - signals.safe_open_activity_tab.emit(key) - signals.add_activity_to_history.emit(key) - - @Slot(name='openActivityGraphExplorer') - def open_graph_explorer(self): - """Open the selected activities in the graph explorer.""" - for key in (self.model.get_key(p) for p in self.selectedIndexes()): - signals.open_activity_graph_tab.emit(key) - - @Slot(name="relinkActivityExchanges") - def relink_activity_exchanges(self) -> None: - for key in (self.model.get_key(a) for a in self.selectedIndexes()): - signals.relink_activity.emit(key) - - @Slot(name="deleteActivities") - def delete_activities(self) -> None: - self.model.delete_activities(self.selectedIndexes()) - - @Slot(name="duplicateActivitiesWithinDb") - def duplicate_activities(self) -> None: - self.model.duplicate_activities(self.selectedIndexes()) - - @Slot(name="duplicateActivitiesToNewLocWithinDb") - def duplicate_activity_to_new_loc(self) -> None: - self.model.duplicate_activity_to_new_loc(self.selectedIndexes()) - - @Slot(name="duplicateActivitiesToOtherDb") - def duplicate_activities_to_db(self) -> None: - self.model.duplicate_activities_to_db(self.selectedIndexes()) - @Slot(name="copyFlowInformation") def copy_exchanges_for_SDF(self) -> None: self.model.copy_exchanges_for_SDF(self.selectedIndexes()) @@ -282,8 +209,8 @@ def sync(self, db_name: str) -> None: def set_context_menu_policy(self) -> None: if self.model.technosphere: self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) - self.db_read_only = project_settings.db_is_readonly(self.database_name) - self.update_activity_table_read_only(self.database_name, self.db_read_only) + self.db_read_only = project_settings.db_is_readonly(self.current_database()) + self.update_activity_table_read_only(self.current_database(), self.db_read_only) else: self.setContextMenuPolicy(QtCore.Qt.NoContextMenu) @@ -293,7 +220,7 @@ def search(self, pattern1: str = None) -> None: @Slot(name="resetSearch") def reset_search(self) -> None: - self.model.sync(self.model.database_name) + self.model.sync(self.model.current_database) @Slot(str, bool, name="updateReadOnly") def update_activity_table_read_only(self, db_name: str, db_read_only: bool) -> None: @@ -303,7 +230,7 @@ def update_activity_table_read_only(self, db_name: str, db_read_only: bool) -> N The user can change state of dbs other than the open one, so check if database name matches. """ - if self.database_name == db_name: + if self.current_database() == db_name: self.db_read_only = db_read_only self.new_activity_action.setEnabled(not self.db_read_only) self.duplicate_activity_action.setEnabled(not self.db_read_only) diff --git a/activity_browser/ui/tables/models/activity.py b/activity_browser/ui/tables/models/activity.py index c06d30c0b..bdb76eebb 100644 --- a/activity_browser/ui/tables/models/activity.py +++ b/activity_browser/ui/tables/models/activity.py @@ -10,9 +10,9 @@ from peewee import DoesNotExist from PySide2.QtCore import QModelIndex, Qt, Slot -from activity_browser import log, signals +from activity_browser import log, signals, actions, exchange_controller from activity_browser.bwutils import ( - PedigreeMatrix, uncertainty as uc, commontasks as bc + PedigreeMatrix, commontasks as bc ) from .base import EditablePandasModel @@ -60,7 +60,7 @@ def create_row(self, exchange) -> dict: except DoesNotExist as e: # The input activity does not exist. remove the exchange. log.info("Broken exchange: {}, removing.".format(e)) - signals.exchanges_deleted.emit([exchange]) + exchange_controller.delete_exchanges([exchange]) def get_exchange(self, proxy: QModelIndex) -> ExchangeProxyBase: idx = self.proxy_to_source(proxy) @@ -75,51 +75,7 @@ def edit_cell(self, proxy: QModelIndex) -> None: col = proxy.column() if self._dataframe.columns[col] in {'Uncertainty', 'pedigree', 'loc', 'scale', 'shape', 'minimum', 'maximum'}: - self.modify_uncertainty(proxy) - - @Slot(list, name="deleteExchanges") - def delete_exchanges(self, proxies: list) -> None: - """ Remove all of the selected exchanges from the activity.""" - exchanges = [self.get_exchange(p) for p in proxies] - signals.exchanges_deleted.emit(exchanges) - - @Slot(list, name="removeFormulas") - def remove_formula(self, proxies: list) -> None: - """ Remove the formulas for all of the selected exchanges. - - This will also check if the exchange has `original_amount` and - attempt to overwrite the `amount` with that value after removing the - `formula` field. - """ - exchanges = [self.get_exchange(p) for p in proxies] - for exchange in exchanges: - signals.exchange_modified.emit(exchange, "formula", "") - - @Slot(QModelIndex, name="modifyExchangeUncertainty") - def modify_uncertainty(self, proxy: QModelIndex) -> None: - """Need to know both keys to select the correct exchange to update.""" - exchange = self.get_exchange(proxy) - signals.exchange_uncertainty_wizard.emit(exchange) - - @Slot(list, name="unsetExchangeUncertainty") - def remove_uncertainty(self, proxies: list) -> None: - exchanges = [self.get_exchange(p) for p in proxies] - for exchange in exchanges: - signals.exchange_uncertainty_modified.emit(exchange, uc.EMPTY_UNCERTAINTY) - - @Slot(list, name="copyFlowInformation") - def copy_exchanges_for_SDF(self, proxies: list) -> None: - exchanges = [] - prev = None - for p in proxies: - e = self.get_exchange(p) - if e is prev: - continue # exact duplicate entry into clipboard - prev = e - exchanges.append(e) - data = bc.get_exchanges_in_scenario_difference_file_notation(exchanges) - df = pd.DataFrame(data) - df.to_clipboard(excel=True, index=False) + actions.ExchangeUncertaintyModify([self.get_exchange(proxy)], self).trigger() @Slot(list, name="openActivities") def open_activities(self, proxies: list) -> None: @@ -138,7 +94,7 @@ def setData(self, index: QModelIndex, value, role=Qt.EditRole): field = bc.AB_names_to_bw_keys.get(header, header) exchange = self._dataframe.iat[index.row(), self.exchange_column] if field in self.VALID_FIELDS: - signals.exchange_modified.emit(exchange, field, value) + actions.ExchangeModify(exchange, {field: value}, self).trigger() else: act_key = exchange.output.key signals.activity_modified.emit(act_key, field, value) diff --git a/activity_browser/ui/tables/models/impact_categories.py b/activity_browser/ui/tables/models/impact_categories.py index 341222e99..768d8b1f4 100644 --- a/activity_browser/ui/tables/models/impact_categories.py +++ b/activity_browser/ui/tables/models/impact_categories.py @@ -30,15 +30,6 @@ def get_method(self, proxy: QModelIndex) -> tuple: idx = self.proxy_to_source(proxy) return self._dataframe.iat[idx.row(), self.method_col] - @Slot(QModelIndex, name="copyMethod") - def copy_method(self, proxy: QModelIndex) -> None: - method = self.get_method(proxy) - signals.copy_method.emit(method, proxy) - - def delete_method(self, proxy: QModelIndex) -> None: - method = self.get_method(proxy) - signals.delete_method.emit(method, proxy) - @Slot(tuple, name="filterOnMethod") def filter_on_method(self, method: tuple) -> None: query = ", ".join(method) @@ -255,11 +246,6 @@ def get_methods(self, name: str) -> Iterator: return queries return methods - @Slot(QModelIndex, name="copyMethod") - def copy_method(self, level: tuple) -> None: - method = self.get_method(level) - signals.copy_method.emit(method, level[0]) - @Slot(QModelIndex, name="deleteMethod") def delete_method(self, level: tuple) -> None: method = self.get_method(level) @@ -312,7 +298,6 @@ def __init__(self, parent=None): self.different_column_types = {k: 'num' for k in self.UNCERTAINTY + ['Amount']} self.filterable_columns = {col: i for i, col in enumerate(self.HEADERS[:-1])} signals.method_modified.connect(self.sync) - self.dataChanged.connect(lambda: signals.cf_changed.emit()) # when a cell is changed, emit this signal @property def uncertain_cols(self) -> list: @@ -365,74 +350,6 @@ def get_col_from_index(self, proxy: QModelIndex) -> str: idx = self.proxy_to_source(proxy) return self.COLUMNS[idx.column()] - @Slot(QModelIndex, name="modifyCFUncertainty") - def modify_uncertainty(self, proxy: QModelIndex) -> None: - """Need to know both keys to select the correct exchange to update.""" - method_cf = self.get_cf(proxy) - wizard = UncertaintyWizard(method_cf, self.parent()) - wizard.complete.connect(self.modify_cf_uncertainty) - wizard.show() - - @Slot(list, name="removeCFUncertainty") - def remove_uncertainty(self, proxy_indexes: Iterator[QModelIndex]) -> None: - to_be_modified = [self.get_cf(p) for p in proxy_indexes] - signals.remove_cf_uncertainties.emit(to_be_modified, self.method.name) - - @Slot(list, name="deleteCF") - def delete_cf(self, proxy_indexes: Iterator[QModelIndex]) -> None: - to_delete = [self.get_cf(p) for p in proxy_indexes] - confirm = QMessageBox() - confirm.setIcon(QMessageBox.Warning) - confirm.setWindowTitle("Confirm deletion") - confirm.setText("Are you sure you want to delete "+str(len(to_delete))+" Characterization Factor(s) from this IC?") - confirm.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) - confirmed = confirm.exec() - if confirmed == QMessageBox.Ok: - log.info("Deleting CF(s):", to_delete) - signals.delete_cf_method.emit(to_delete, self.method.name) - - @Slot(tuple, object, name="modifyCfUncertainty") - def modify_cf_uncertainty(self, cf: tuple, uncertainty: dict) -> None: - """Update the CF with new uncertainty information, possibly converting - the second item in the tuple to a dictionary without losing information. - """ - data = [*cf] - if isinstance(data[1], dict): - data[1].update(uncertainty) - else: - uncertainty["amount"] = data[1] - data[1] = uncertainty - signals.edit_method_cf.emit(tuple(data), self.method.name) - - @Slot(tuple, object, name="modifyCfUncertainty") - def modify_cf(self, proxy: QModelIndex) -> None: - """Update the CF with new data, possibly converting - the second item in the tuple to a dictionary without losing information. - - Parameters - ---------- - proxy : QModelIndex - The Qt object of the selected cell in the table. - """ - cf = self.get_cf(proxy) - col_name = self.get_col_from_index(proxy) - new_val = self.get_value(proxy) - new_data = {col_name: new_val} - - data = [*cf] - if isinstance(data[1], dict): - data[1].update(new_data) - else: - if col_name == 'amount': - # if the new value we're writing is indeed 'amount', write that value, otherwise, just take the current - amt = new_val - else: - amt = data[1] - - new_data["amount"] = amt - data[1] = new_data - signals.edit_method_cf.emit(tuple(data), self.method.name) - def set_filterable_columns(self, hide: bool) -> None: filterable_cols = {col: i for i, col in enumerate(self.HEADERS[:-1])} if not hide: diff --git a/activity_browser/ui/tables/models/inventory.py b/activity_browser/ui/tables/models/inventory.py index e63d466d4..77771942f 100644 --- a/activity_browser/ui/tables/models/inventory.py +++ b/activity_browser/ui/tables/models/inventory.py @@ -160,31 +160,6 @@ def filter_dataframe(self, df: pd.DataFrame, pattern: str) -> pd.Series: ) return mask - def delete_activities(self, proxies: list) -> None: - if len(proxies) > 1: - keys = [self.get_key(p) for p in proxies] - signals.delete_activities.emit(keys) - else: - signals.delete_activity.emit(self.get_key(proxies[0])) - - def duplicate_activities(self, proxies: list) -> None: - if len(proxies) > 1: - keys = [self.get_key(p) for p in proxies] - signals.duplicate_activities.emit(keys) - else: - signals.duplicate_activity.emit(self.get_key(proxies[0])) - - def duplicate_activity_to_new_loc(self, proxies: list) -> None: - signals.duplicate_activity_new_loc.emit(self.get_key(proxies[0])) - - def duplicate_activities_to_db(self, proxies: list) -> None: - if len(proxies) > 1: - keys = [self.get_key(p) for p in proxies] - signals.duplicate_to_db_interface_multiple.emit(keys, self.database_name) - else: - key = self.get_key(proxies[0]) - signals.duplicate_to_db_interface.emit(key, self.database_name) - def copy_exchanges_for_SDF(self, proxies: list) -> None: if len(proxies) > 1: keys = [self.get_key(p) for p in proxies] diff --git a/activity_browser/ui/tables/models/lca_setup.py b/activity_browser/ui/tables/models/lca_setup.py index 461924534..c8556bc1b 100644 --- a/activity_browser/ui/tables/models/lca_setup.py +++ b/activity_browser/ui/tables/models/lca_setup.py @@ -84,6 +84,9 @@ def __init__(self, parent=None): @property def activities(self) -> list: + # if no dataframe is present return empty list + if not isinstance(self._dataframe, pd.DataFrame): return [] + # else return the selected activities selection = self._dataframe.loc[:, ["Amount", "key"]].to_dict(orient="records") return [{x["key"]: x["Amount"]} for x in selection] diff --git a/activity_browser/ui/tables/models/parameters.py b/activity_browser/ui/tables/models/parameters.py index 5a7c2399b..a855ad6be 100644 --- a/activity_browser/ui/tables/models/parameters.py +++ b/activity_browser/ui/tables/models/parameters.py @@ -9,12 +9,14 @@ from asteval import Interpreter from peewee import DoesNotExist from PySide2.QtCore import Slot, QModelIndex +from PySide2 import QtWidgets -from activity_browser import log, signals +from activity_browser import log, signals, application from activity_browser.bwutils import commontasks as bc, uncertainty as uc from activity_browser.ui.wizards import UncertaintyWizard from .base import BaseTreeModel, EditablePandasModel, TreeItem - +from ....controllers import parameter_controller, exchange_controller +from ....actions import ParameterRename class BaseParameterModel(EditablePandasModel): COLUMNS = [] @@ -80,17 +82,25 @@ def edit_single_parameter(self, index: QModelIndex) -> None: """Take the index and update the underlying brightway Parameter.""" param = self.get_parameter(index) field = self._dataframe.columns[index.column()] - signals.parameter_modified.emit(param, field, index.data()) + + try: + parameter_controller.modify_parameter(param, field, index.data()) + except Exception as e: + QtWidgets.QMessageBox.warning( + application.main_window, "Could not save changes", str(e), + QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok + ) @Slot(QModelIndex, name="startRenameParameter") def handle_parameter_rename(self, proxy: QModelIndex) -> None: group = self.get_group(proxy) param = self.get_parameter(proxy) - signals.rename_parameter.emit(param, group) + + ParameterRename(param, self).trigger() def delete_parameter(self, proxy: QModelIndex) -> None: param = self.get_parameter(proxy) - signals.delete_parameter.emit(param) + parameter_controller.delete_parameter(param) @Slot(name="modifyParameterUncertainty") def modify_uncertainty(self, proxy: QModelIndex) -> None: @@ -101,7 +111,7 @@ def modify_uncertainty(self, proxy: QModelIndex) -> None: @Slot(name="unsetParameterUncertainty") def remove_uncertainty(self, proxy: QModelIndex) -> None: param = self.get_parameter(proxy) - signals.parameter_uncertainty_modified.emit(param, uc.EMPTY_UNCERTAINTY) + parameter_controller.modify_parameter_uncertainty(param, uc.EMPTY_UNCERTAINTY) def handle_double_click(self, proxy: QModelIndex) -> None: column = proxy.column() @@ -223,7 +233,7 @@ def sync(self) -> None: @classmethod def parse_parameter(cls, parameter) -> dict: - """ Override the base method to add more steps. + """ Override the base.py method to add more steps. """ row = super().parse_parameter(parameter) # Combine the 'database' and 'code' fields of the parameter into a 'key' @@ -233,9 +243,7 @@ def parse_parameter(cls, parameter) -> dict: except: # Can occur if an activity parameter exists for a removed activity. log.info("Activity {} no longer exists, removing parameter.".format(row["key"])) - signals.clear_activity_parameter.emit( - parameter.database, parameter.code, parameter.group - ) + parameter_controller.clear_broken_activity_parameter(parameter.database, parameter.code, parameter.group) return {} row["product"] = act.get("reference product") or act.get("name") row["activity"] = act.get("name") @@ -247,7 +255,7 @@ def parse_parameter(cls, parameter) -> dict: @staticmethod @Slot(tuple, name="addActivityParameter") def add_parameter(key: tuple) -> None: - signals.add_activity_parameter.emit(key) + parameter_controller.auto_add_parameter(key) def get_activity_groups(self, proxy, ignore_groups: list = None) -> Iterable[str]: """ Helper method to look into the Group and determine which if any @@ -355,7 +363,7 @@ def build_exchanges(cls, act_param, parent: TreeItem) -> None: except DoesNotExist as e: # The exchange is coming from a deleted database, remove it log.warning("Broken exchange: {}, removing.".format(e)) - signals.exchanges_deleted.emit([exc]) + exchange_controller.delete_exchanges([exc]) class ParameterTreeModel(BaseTreeModel): @@ -419,7 +427,7 @@ def parameterize_exchanges(self, key: tuple) -> None: group = bc.build_activity_group_name(key) if not (ActivityParameter.select() .where(ActivityParameter.group == group).count()): - signals.add_activity_parameter.emit(key) + parameter_controller.auto_add_parameter(key) act = bw.get_activity(key) with bw.parameters.db.atomic(): diff --git a/activity_browser/ui/tables/parameters.py b/activity_browser/ui/tables/parameters.py index 5fb28dc8d..0df5ee528 100644 --- a/activity_browser/ui/tables/parameters.py +++ b/activity_browser/ui/tables/parameters.py @@ -6,6 +6,7 @@ from ...settings import project_settings from ...signals import signals +from ...actions import ParameterNewAutomatic from ..icons import qicons from .delegates import * from .models import ( @@ -197,7 +198,7 @@ def dropEvent(self, event: QDropEvent) -> None: db_table = event.source() if project_settings.settings["read-only-databases"].get( - db_table.database_name, True): + db_table.current_database(), True): QMessageBox.warning( self, "Not allowed", "Cannot set activity parameters on read-only databases", @@ -207,7 +208,7 @@ def dropEvent(self, event: QDropEvent) -> None: keys = [db_table.get_key(i) for i in db_table.selectedIndexes()] event.accept() - signals.add_activity_parameters.emit(keys) + ParameterNewAutomatic(keys, self).trigger() def contextMenuEvent(self, event: QContextMenuEvent) -> None: """ Override and activate QTableView.contextMenuEvent() diff --git a/activity_browser/ui/tables/projects.py b/activity_browser/ui/tables/projects.py index 3390fe165..8a48869b4 100644 --- a/activity_browser/ui/tables/projects.py +++ b/activity_browser/ui/tables/projects.py @@ -3,6 +3,7 @@ from PySide2.QtWidgets import QComboBox from PySide2.QtCore import Qt from ...signals import signals +from ...controllers import project_controller class ProjectListWidget(QComboBox): @@ -26,4 +27,4 @@ def sync(self): self.setCurrentIndex(index) def on_activated(self, index): - signals.change_project.emit(self.project_names[index]) + project_controller.change_project(self.project_names[index]) diff --git a/activity_browser/ui/tables/views.py b/activity_browser/ui/tables/views.py index ae291da3a..68c34352b 100644 --- a/activity_browser/ui/tables/views.py +++ b/activity_browser/ui/tables/views.py @@ -129,7 +129,7 @@ def keyPressEvent(self, e): class ABFilterableDataFrameView(ABDataFrameView): - """ Filterable base class for showing pandas dataframe objects as tables. + """ Filterable base.py class for showing pandas dataframe objects as tables. To use this table, the following MUST be set in the table model: - self.filterable_columns: dict diff --git a/activity_browser/ui/widgets/__init__.py b/activity_browser/ui/widgets/__init__.py index 5b2c029f0..3d7e0745f 100644 --- a/activity_browser/ui/widgets/__init__.py +++ b/activity_browser/ui/widgets/__init__.py @@ -3,7 +3,6 @@ from .biosphere_update import BiosphereUpdater from .comparison_switch import SwitchComboBox from .cutoff_menu import CutoffMenu -from .database_copy import CopyDatabaseDialog from .dialog import ( ForceInputDialog, TupleNameDialog, ExcelReadDialog, DatabaseLinkingDialog, DefaultBiosphereDialog, diff --git a/activity_browser/ui/widgets/biosphere_update.py b/activity_browser/ui/widgets/biosphere_update.py index cdf7873bd..b94dc26d3 100644 --- a/activity_browser/ui/widgets/biosphere_update.py +++ b/activity_browser/ui/widgets/biosphere_update.py @@ -20,16 +20,17 @@ def __init__(self, ei_versions, parent=None): self.thread = UpdateBiosphereThread(ei_versions, self) self.setMaximum(self.thread.total_patches) self.thread.progress.connect(self.update_progress) - self.thread.finished.connect(self.finished) + self.thread.finished.connect(self.thread_finished) self.thread.start() - def finished(self, result: int = None) -> None: + def thread_finished(self, result: int = None) -> None: outcome = result or 0 self.thread.exit(outcome) self.setMaximum(1) self.setValue(1) signals.database_changed.emit(bw.config.biosphere) signals.databases_changed.emit() + self.done(outcome) @Slot(int) def update_progress(self, current: int): diff --git a/activity_browser/ui/widgets/database_copy.py b/activity_browser/ui/widgets/database_copy.py deleted file mode 100644 index 404212daa..000000000 --- a/activity_browser/ui/widgets/database_copy.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from PySide2 import QtCore, QtWidgets - -from ...signals import signals -from ..threading import ABThread - - -class CopyDatabaseDialog(QtWidgets.QProgressDialog): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setWindowTitle('Copying database') - self.setModal(QtCore.Qt.ApplicationModal) - self.setRange(0, 0) - - self.thread = CopyDatabaseThread(self) - self.thread.finished.connect(self.finished) - - def begin_copy(self, copy_from: str, copy_to: str) -> None: - if not all([copy_from, copy_to]): - raise ValueError("Copy information not configured") - if copy_from not in bw.databases: - raise ValueError("Database {} does not exist!".format(copy_from)) - if copy_to in bw.databases: - raise ValueError("Database {} already exists!".format(copy_to)) - self.setLabelText( - 'Copying existing database {} to new database {}:'.format( - copy_from, copy_to) - ) - self.thread.configure(copy_from, copy_to) - self.thread.start() - - def finished(self, result: int = None) -> None: - self.thread.exit(result or 0) - self.setMaximum(1) - self.setValue(1) - signals.databases_changed.emit() - - -class CopyDatabaseThread(ABThread): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.copy_from = None - self.copy_to = None - - def configure(self, copy_from: str, copy_to: str): - self.copy_from = copy_from - self.copy_to = copy_to - - def run_safely(self): - bw.Database(self.copy_from).copy(self.copy_to) diff --git a/activity_browser/ui/widgets/dialog.py b/activity_browser/ui/widgets/dialog.py index 57362821a..900746773 100644 --- a/activity_browser/ui/widgets/dialog.py +++ b/activity_browser/ui/widgets/dialog.py @@ -16,6 +16,7 @@ from ...info import __ei_versions__ from ...bwutils.ecoinvent_biosphere_versions.ecospold2biosphereimporter import create_default_biosphere3 from ...utils import sort_semantic_versions +from ...controllers import project_controller class ForceInputDialog(QtWidgets.QDialog): """ Due to QInputDialog not allowing 'ok' button to be disabled when @@ -521,7 +522,7 @@ def __init__(self, version, parent=None): self.biosphere_thread = DefaultBiosphereThread(self.version, self) self.biosphere_thread.update.connect(self.update_progress) - self.biosphere_thread.finished.connect(self.finished) + self.biosphere_thread.finished.connect(self.thread_finished) self.biosphere_thread.start() # finally, check if patches are available for this version and apply them @@ -532,12 +533,13 @@ def update_progress(self, current: int, text: str) -> None: self.setValue(current) self.setLabelText(text) - def finished(self, result: int = None) -> None: + def thread_finished(self, result: int = None) -> None: self.biosphere_thread.exit(result or 0) self.setValue(3) self.check_patches() - signals.change_project.emit(bw.projects.current) + project_controller.change_project(bw.projects.current) signals.project_selected.emit() + self.done(result or 0) def check_patches(self): """Apply any relevant biosphere patches if available.""" diff --git a/activity_browser/ui/widgets/line_edit.py b/activity_browser/ui/widgets/line_edit.py index dabd70e4c..e326a2cf5 100644 --- a/activity_browser/ui/widgets/line_edit.py +++ b/activity_browser/ui/widgets/line_edit.py @@ -95,6 +95,6 @@ def focusOutEvent(self, event): super(SignalledComboEdit, self).focusOutEvent(event) # def showPopup(self): - # """Overrides the base class function.""" + # """Overrides the base.py class function.""" # self.populate_combobox.emit() # super(SignalledComboEdit, self).showPopup() diff --git a/activity_browser/ui/wizards/settings_wizard.py b/activity_browser/ui/wizards/settings_wizard.py index 13cd4da5a..41b989190 100644 --- a/activity_browser/ui/wizards/settings_wizard.py +++ b/activity_browser/ui/wizards/settings_wizard.py @@ -6,6 +6,7 @@ from peewee import SqliteDatabase from activity_browser import log, signals, ab_settings +from ...controllers import project_controller class SettingsWizard(QtWidgets.QWizard): @@ -40,13 +41,13 @@ def save_settings(self): log.info("Saved startup project as: ", new_startup_project) ab_settings.write_settings() - signals.switch_bw2_dir_path.emit(field) + project_controller.switch_brightway2_dir_path(field) def cancel(self): log.info("Going back to before settings were changed.") if bw.projects._base_data_dir != self.last_bwdir: - signals.switch_bw2_dir_path.emit(self.last_bwdir) - signals.change_project.emit(self.last_project) # project changes only if directory is changed + project_controller.switch_brightway2_dir_path(self.last_bwdir) + project_controller.change_project(self.last_project) # project changes only if directory is changed class SettingsPage(QtWidgets.QWizardPage): diff --git a/activity_browser/ui/wizards/uncertainty.py b/activity_browser/ui/wizards/uncertainty.py index 24983104b..3fc6010ac 100644 --- a/activity_browser/ui/wizards/uncertainty.py +++ b/activity_browser/ui/wizards/uncertainty.py @@ -5,7 +5,7 @@ from stats_arrays import uncertainty_choices as uncertainty from stats_arrays.distributions import * -from activity_browser import log, signals +from activity_browser import log, signals, application, parameter_controller, exchange_controller from ..figures import SimpleDistributionPlot from ..style import style_group_box from ...bwutils import PedigreeMatrix, get_uncertainty_interface @@ -75,13 +75,13 @@ def update_uncertainty(self): """ self.amount_mean_test() if self.obj.data_type == "exchange": - signals.exchange_uncertainty_modified.emit(self.obj.data, self.uncertainty_info) + exchange_controller.edit_exchange(self.obj.data, self.uncertainty_info) if self.using_pedigree: - signals.exchange_pedigree_modified.emit(self.obj.data, self.pedigree.matrix.factors) + exchange_controller.edit_exchange(self.obj.data, {"pedigree": self.pedigree.matrix.factors}) elif self.obj.data_type == "parameter": - signals.parameter_uncertainty_modified.emit(self.obj.data, self.uncertainty_info) + parameter_controller.modify_parameter_uncertainty(self.obj.data, self.uncertainty_info) if self.using_pedigree: - signals.parameter_pedigree_modified.emit(self.obj.data, self.pedigree.matrix.factors) + parameter_controller.modify_parameter_pedigree(self.obj.data, self.pedigree.matrix.factors) elif self.obj.data_type == "cf": self.complete.emit(self.obj.data, self.uncertainty_info) @@ -141,9 +141,17 @@ def amount_mean_test(self) -> None: ) if choice == QtWidgets.QMessageBox.Yes: if self.obj.data_type == "exchange": - signals.exchange_modified.emit(self.obj.data, "amount", mean) + exchange_controller.edit_exchange(self.obj.data, {"amount": mean}) + elif self.obj.data_type == "parameter": signals.parameter_modified.emit(self.obj.data, "amount", mean) + try: + parameter_controller.modify_parameter(self.obj.data, "amount", mean) + except Exception as e: + QtWidgets.QMessageBox.warning( + application.main_window, "Could not save changes", str(e), + QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok + ) elif self.obj.data_type == "cf": altered = {k: v for k, v in self.obj.uncertainty.items()} altered["amount"] = mean diff --git a/tests/actions/test_activity_actions.py b/tests/actions/test_activity_actions.py new file mode 100644 index 000000000..3898fff15 --- /dev/null +++ b/tests/actions/test_activity_actions.py @@ -0,0 +1,145 @@ +import pytest +import brightway2 as bw +from PySide2 import QtWidgets +from activity_browser import actions, database_controller +from activity_browser.ui.widgets.dialog import LocationLinkingDialog, ActivityLinkingDialog + + +def test_activity_delete(ab_app, monkeypatch): + key = ('activity_tests', '330b935a46bc4ad39530ab7df012f38b') + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'warning', + staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes) + ) + + + assert bw.projects.current == "default" + assert bw.get_activity(key) + + actions.ActivityDelete([key], None).trigger() + + with pytest.raises(Exception): bw.get_activity(key) + + +def test_activity_duplicate(ab_app): + key = ('activity_tests', 'dd4e2393573c49248e7299fbe03a169c') + dup_key = ('activity_tests', 'dd4e2393573c49248e7299fbe03a169c_copy1') + + assert bw.projects.current == "default" + assert bw.get_activity(key) + with pytest.raises(Exception): bw.get_activity(dup_key) + + actions.ActivityDuplicate([key], None).trigger() + + assert bw.get_activity(key) + assert bw.get_activity(dup_key) + + +def test_activity_duplicate_to_db(ab_app, monkeypatch): + key = ('activity_tests', 'dd4e2393573c49248e7299fbe03a169c') + dup_key = ('db_to_duplicate_to', 'dd4e2393573c49248e7299fbe03a169c_copy1') + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getItem', + staticmethod(lambda *args, **kwargs: ('db_to_duplicate_to', True)) + ) + + assert bw.projects.current == "default" + assert bw.get_activity(key) + with pytest.raises(Exception): bw.get_activity(dup_key) + + actions.ActivityDuplicateToDB([key], None).trigger() + + assert bw.get_activity(key) + assert bw.get_activity(dup_key) + + +def test_activity_duplicate_to_loc(ab_app, monkeypatch): + key = ('activity_tests', 'dd4e2393573c49248e7299fbe03a169c') + dup_key = ('activity_tests', 'dd4e2393573c49248e7299fbe03a169c_copy2') + + monkeypatch.setattr( + LocationLinkingDialog, 'exec_', + staticmethod(lambda *args, **kwargs: True) + ) + + monkeypatch.setattr( + LocationLinkingDialog, 'relink', + {"MOON": "GLO"} + ) + + assert bw.projects.current == "default" + assert bw.get_activity(key).as_dict()["location"] == "MOON" + with pytest.raises(Exception): bw.get_activity(dup_key) + + actions.ActivityDuplicateToLoc([key], None).trigger() + + assert bw.get_activity(key).as_dict()["location"] == "MOON" + assert bw.get_activity(dup_key).as_dict()["location"] == "GLO" + + +def test_activity_graph(ab_app): + key = ('activity_tests', '3fcde3e3bf424e97b32cf29347ac7f33') + panel = ab_app.main_window.right_panel.tabs["Graph Explorer"] + + assert bw.projects.current == "default" + assert bw.get_activity(key) + assert key not in panel.tabs + + actions.ActivityGraph([key], None).trigger() + + assert key in panel.tabs + + +def test_activity_new(ab_app, monkeypatch): + database_name = "activity_tests" + records = database_controller.record_count(database_name) + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('activity_that_is_new', True)) + ) + + actions.ActivityNew(database_name, None).trigger() + + assert records < database_controller.record_count(database_name) + + +def test_activity_open(ab_app): + key = ('activity_tests', '3fcde3e3bf424e97b32cf29347ac7f33') + panel = ab_app.main_window.right_panel.tabs["Activity Details"] + + assert bw.projects.current == "default" + assert bw.get_activity(key) + assert key not in panel.tabs + + actions.ActivityOpen([key], None).trigger() + + assert key in panel.tabs + assert panel.isVisible() + + +def test_activity_relink(ab_app, monkeypatch, qtbot): + key = ('activity_tests', '834c9010dff24c138c8ffa19924e5935') + from_key = ('db_to_relink_from', '6a98a991da90495ea599e35b3d3602ab') + to_key = ('db_to_relink_to', '0d4d83e3baee4b7e865c34a16a63f03e') + + monkeypatch.setattr( + ActivityLinkingDialog, 'exec_', + staticmethod(lambda *args, **kwargs: True) + ) + + monkeypatch.setattr( + ActivityLinkingDialog, 'relink', + {"db_to_relink_from": "db_to_relink_to"} + ) + + assert bw.projects.current == "default" + assert list(bw.get_activity(key).exchanges())[1].input.key == from_key + + actions.ActivityRelink([key], None).trigger() + + assert list(bw.get_activity(key).exchanges())[1].input.key == to_key + + diff --git a/tests/actions/test_calculation_setup_actions.py b/tests/actions/test_calculation_setup_actions.py new file mode 100644 index 000000000..4d53e1c07 --- /dev/null +++ b/tests/actions/test_calculation_setup_actions.py @@ -0,0 +1,80 @@ +import brightway2 as bw +from activity_browser import actions +from PySide2 import QtWidgets + + +def test_cs_delete(ab_app, monkeypatch): + cs = "cs_to_delete" + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'warning', + staticmethod(lambda *args, **kwargs: True) + ) + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'information', + staticmethod(lambda *args, **kwargs: True) + ) + + assert bw.projects.current == "default" + assert cs in bw.calculation_setups + + actions.CSDelete(cs, None).trigger() + + assert cs not in bw.calculation_setups + + +def test_cs_duplicate(ab_app, monkeypatch): + cs = "cs_to_duplicate" + dup_cs = "cs_that_is_duplicated" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('cs_that_is_duplicated', True)) + ) + + assert bw.projects.current == "default" + assert cs in bw.calculation_setups + assert dup_cs not in bw.calculation_setups + + actions.CSDuplicate(cs, None).trigger() + + assert cs in bw.calculation_setups + assert dup_cs in bw.calculation_setups + + +def test_cs_new(ab_app, monkeypatch): + new_cs = "cs_that_is_new" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('cs_that_is_new', True)) + ) + + assert bw.projects.current == "default" + assert new_cs not in bw.calculation_setups + + actions.CSNew(None).trigger() + + assert new_cs in bw.calculation_setups + + return + + +def test_cs_rename(ab_app, monkeypatch): + cs = "cs_to_rename" + renamed_cs = "cs_that_is_renamed" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('cs_that_is_renamed', True)) + ) + + assert bw.projects.current == "default" + assert cs in bw.calculation_setups + assert renamed_cs not in bw.calculation_setups + + actions.CSRename(cs, None).trigger() + + assert cs not in bw.calculation_setups + assert renamed_cs in bw.calculation_setups diff --git a/tests/actions/test_database_actions.py b/tests/actions/test_database_actions.py new file mode 100644 index 000000000..b79429216 --- /dev/null +++ b/tests/actions/test_database_actions.py @@ -0,0 +1,118 @@ +import brightway2 as bw +from activity_browser import actions +from PySide2 import QtWidgets +from activity_browser.ui.widgets.dialog import DatabaseLinkingDialog + + +def test_database_delete(ab_app, monkeypatch): + db = "db_to_delete" + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'question', + staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes) + ) + + assert bw.projects.current == "default" + assert db in bw.databases + + actions.DatabaseDelete(db, None).trigger() + + assert db not in bw.databases + + +def test_database_duplicate(ab_app, monkeypatch, qtbot): + db = "db_to_duplicate" + dup_db = "db_that_is_duplicated" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('db_that_is_duplicated', True)) + ) + + assert bw.projects.current == "default" + assert db in bw.databases + assert dup_db not in bw.databases + + action = actions.DatabaseDuplicate(db, None) + action.trigger() + + with qtbot.waitSignal(action.dialog.thread.finished, timeout=60*1000): + pass + + assert db in bw.databases + assert dup_db in bw.databases + + +def test_database_export(ab_app): + # TODO: implement when we've redone the export wizard and actions + action = actions.DatabaseExport(None) + action.trigger() + assert action.wizard.isVisible() + action.wizard.destroy() + return + + +def test_database_import(ab_app): + # TODO: implement when we've redone the import wizard and actions + action = actions.DatabaseImport(None) + action.trigger() + assert action.wizard.isVisible() + action.wizard.destroy() + return + + +def test_database_new(ab_app, monkeypatch): + new_db = "db_that_is_new" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ('db_that_is_new', True)) + ) + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'information', + staticmethod(lambda *args, **kwargs: True) + ) + + assert bw.projects.current == "default" + assert new_db not in bw.databases + + actions.DatabaseNew(None).trigger() + + assert new_db in bw.databases + + db_number = len(bw.databases) + + actions.DatabaseNew(None).trigger() + + assert db_number == len(bw.databases) + + +def test_database_relink(ab_app, monkeypatch): + db = "db_to_relink" + from_db = "db_to_relink_from" + to_db = "db_to_relink_to" + + monkeypatch.setattr( + DatabaseLinkingDialog, 'exec_', + staticmethod(lambda *args, **kwargs: DatabaseLinkingDialog.Accepted) + ) + + monkeypatch.setattr( + DatabaseLinkingDialog, 'relink', + {"db_to_relink_from": "db_to_relink_to"} + ) + + assert db in bw.databases + assert from_db in bw.databases + assert to_db in bw.databases + assert from_db in bw.Database(db).find_dependents() + assert to_db not in bw.Database(db).find_dependents() + + actions.DatabaseRelink(db, None).trigger() + + assert db in bw.databases + assert from_db in bw.databases + assert to_db in bw.databases + assert from_db not in bw.Database(db).find_dependents() + assert to_db in bw.Database(db).find_dependents() diff --git a/tests/actions/test_exchange_actions.py b/tests/actions/test_exchange_actions.py new file mode 100644 index 000000000..de4b486ac --- /dev/null +++ b/tests/actions/test_exchange_actions.py @@ -0,0 +1,116 @@ +import pytest +import platform +import brightway2 as bw +from activity_browser import actions +from PySide2 import QtWidgets, QtGui +from stats_arrays.distributions import NormalUncertainty, UndefinedUncertainty + + +def test_exchange_copy_sdf(ab_app): + # this test will always fail on the linux automated test because it doesn't have a clipboard + if platform.system() == "Linux": return + + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '77780c6ab87d4e8785172f107877d6ed') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + clipboard = QtGui.QClipboard() + clipboard.setText("FAILED") + + assert bw.projects.current == "default" + assert len(exchange) == 1 + assert clipboard.text() == "FAILED" + + actions.ExchangeCopySDF(exchange, None).trigger() + + assert clipboard.text() != "FAILED" + + return + + +def test_exchange_delete(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '3b86eaea74ff40d69e9e6bec137a8f0c') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + assert bw.projects.current == "default" + assert len(exchange) == 1 + assert exchange[0].as_dict() in [exchange.as_dict() for exchange in bw.get_activity(key).exchanges()] + + actions.ExchangeDelete(exchange, None).trigger() + + assert exchange[0].as_dict() not in [exchange.as_dict() for exchange in bw.get_activity(key).exchanges()] + + +def test_exchange_formula_remove(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '19437c81de6545ad8d017ee2e2fa32e6') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + assert bw.projects.current == "default" + assert len(exchange) == 1 + assert exchange[0].as_dict()["formula"] + + actions.ExchangeFormulaRemove(exchange, None).trigger() + + with pytest.raises(KeyError): assert exchange[0].as_dict()["formula"] + + +def test_exchange_modify(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '0e1dc99927284e45af17d546414a3ccd') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + new_data = {"amount": "200"} + + assert bw.projects.current == "default" + assert len(exchange) == 1 + assert exchange[0].amount == 1.0 + + actions.ExchangeModify(exchange[0], new_data, None).trigger() + + assert exchange[0].amount == 200.0 + + +def test_exchange_new(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('activity_tests', 'be8fb2776c354aa7ad61d8348828f3af') + + assert bw.projects.current == "default" + assert not [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + actions.ExchangeNew([from_key], key, None).trigger() + + assert len([exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key]) == 1 + + +def test_exchange_uncertainty_modify(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '5ad223731bd244e997623b0958744017') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + action = actions.ExchangeUncertaintyModify(exchange, None) + + assert bw.projects.current == "default" + assert len(exchange) == 1 + with pytest.raises(Exception): assert action.wizard + + action.trigger() + + assert action.wizard.isVisible() + + action.wizard.destroy() + + +def test_exchange_uncertainty_remove(ab_app): + key = ('exchange_tests', '186cdea4c3214479b931428591ab2021') + from_key = ('exchange_tests', '4e28577e29a346e3aef6aeafb6d5eb65') + exchange = [exchange for exchange in bw.get_activity(key).exchanges() if exchange.input.key == from_key] + + assert bw.projects.current == "default" + assert len(exchange) == 1 + assert exchange[0].uncertainty_type == NormalUncertainty + + actions.ExchangeUncertaintyRemove(exchange, None).trigger() + + assert exchange[0].uncertainty_type == UndefinedUncertainty diff --git a/tests/actions/test_method_actions.py b/tests/actions/test_method_actions.py new file mode 100644 index 000000000..c9f06fc42 --- /dev/null +++ b/tests/actions/test_method_actions.py @@ -0,0 +1,145 @@ +import brightway2 as bw +import pytest +from activity_browser import actions +from activity_browser.ui.widgets.dialog import TupleNameDialog +from stats_arrays.distributions import NormalUncertainty, UndefinedUncertainty, UniformUncertainty +from PySide2 import QtWidgets + + +def test_cf_amount_modify(ab_app): + method = ("A_methods", "methods", "method") + key = ('biosphere3', '595f08d9-6304-497e-bb7d-48b6d2d8bff3') + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + assert bw.projects.current == "default" + assert len(cf) == 1 + assert cf[0][1] == 1.0 or cf[0][1]['amount'] == 1.0 + + actions.CFAmountModify(method, cf, 200, None).trigger() + + assert cf[0][1] == 200.0 or cf[0][1]['amount'] == 200.0 + + +def test_cf_new(ab_app): + method = ("A_methods", "methods", "method") + key = ('biosphere3', '0d9f52b2-f2d5-46a3-90a3-e22ef252cc37') + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + assert bw.projects.current == "default" + assert len(cf) == 0 + + actions.CFNew(method, [key], None).trigger() + + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + assert len(cf) == 1 + assert cf[0][1] == 0.0 + + +def test_cf_remove(ab_app, monkeypatch): + method = ("A_methods", "methods", "method") + key = ('biosphere3', '075e433b-4be4-448e-9510-9a5029c1ce94') + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'warning', + staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes) + ) + + assert bw.projects.current == "default" + assert len(cf) == 1 + + actions.CFRemove(method, cf, None).trigger() + + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + assert len(cf) == 0 + + +def test_cf_uncertainty_modify(ab_app): + method = ("A_methods", "methods", "method") + key = ('biosphere3', 'da5e6be3-ed71-48ac-9397-25bac666c7b7') + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + new_cf_tuple = (('biosphere3', 'da5e6be3-ed71-48ac-9397-25bac666c7b7'), {'amount': 5.5}) + uncertainty = {'loc': float('nan'), 'maximum': 10.0, 'minimum': 1.0, 'negative': False, 'scale': float('nan'), + 'shape': float('nan'), 'uncertainty type': 4} + + action = actions.CFUncertaintyModify(method, cf, None) + + assert bw.projects.current == "default" + assert len(cf) == 1 + assert cf[0][1].get("uncertainty type") == NormalUncertainty.id + + with pytest.raises(Exception): assert action.wizard + + action.trigger() + + assert action.wizard.isVisible() + + action.wizard.destroy() + action.wizardDone(new_cf_tuple, uncertainty) + + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + assert cf[0][1].get("uncertainty type") == UniformUncertainty.id + assert cf[0][1].get("amount") == 5.5 + + +def test_cf_uncertainty_remove(ab_app): + method = ("A_methods", "methods", "method") + key = ('biosphere3', '2a7b68ff-f12a-44c6-8b31-71ec91d29889') + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + + assert bw.projects.current == "default" + assert len(cf) == 1 + assert cf[0][1].get("uncertainty type") == NormalUncertainty.id + + actions.CFUncertaintyRemove(method, cf, None).trigger() + + cf = [cf for cf in bw.Method(method).load() if cf[0] == key] + assert cf[0][1] == 1.0 or cf[0][1].get("uncertainty type") == UndefinedUncertainty.id + + +def test_method_delete(ab_app, monkeypatch): + method = ("A_methods", "methods", "method_to_delete") + branch = ("A_methods", "methods_to_delete") + branched_method = ("A_methods", "methods_to_delete", "method_to_delete") + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'warning', + staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Yes) + ) + + assert bw.projects.current == "default" + assert method in bw.methods + assert branched_method in bw.methods + + actions.MethodDelete([method], 'leaf', None).trigger() + actions.MethodDelete([branch], 'branch', None).trigger() + + assert method not in bw.methods + assert branched_method not in bw.methods + + +def test_method_duplicate(ab_app, monkeypatch): + method = ("A_methods", "methods", "method_to_duplicate") + result = ("A_methods", "duplicated_methods") + duplicated_method = ("A_methods", "duplicated_methods", "method_to_duplicate") + + monkeypatch.setattr( + TupleNameDialog, 'exec_', + staticmethod(lambda *args, **kwargs: TupleNameDialog.Accepted) + ) + + monkeypatch.setattr( + TupleNameDialog, 'result_tuple', + result + ) + + assert method in bw.methods + assert duplicated_method not in bw.methods + + actions.MethodDuplicate([method], 'leaf', None).trigger() + + assert method in bw.methods + assert duplicated_method in bw.methods + diff --git a/tests/actions/test_parameter_actions.py b/tests/actions/test_parameter_actions.py new file mode 100644 index 000000000..5f894efad --- /dev/null +++ b/tests/actions/test_parameter_actions.py @@ -0,0 +1,171 @@ +import brightway2 as bw +from bw2data.parameters import ProjectParameter, DatabaseParameter, ActivityParameter +from PySide2 import QtWidgets +from activity_browser import actions + +from activity_browser.actions.parameter.parameter_new import ParameterWizard + + +class TestParameterNew: + def test_parameter_new_project(self, ab_app, monkeypatch): + key = ("", "") + param_data = { + "name": "project_parameter_to_be_created", + "amount": "1.0" + } + + monkeypatch.setattr( + ParameterWizard, 'exec_', + staticmethod(lambda *args, **kwargs: ParameterWizard.Accepted) + ) + monkeypatch.setattr(ParameterWizard, 'selected', 0) + monkeypatch.setattr(ParameterWizard, 'param_data', param_data) + + assert bw.projects.current == "default" + assert "project_parameter_to_be_created" not in ProjectParameter.load().keys() + + actions.ParameterNew(key, None).trigger() + + assert "project_parameter_to_be_created" in ProjectParameter.load().keys() + + def test_parameter_new_database(self, ab_app, monkeypatch): + key = ("db", "") + param_data = { + "name": "database_parameter_to_be_created", + "database": "activity_tests", + "amount": "1.0" + } + + monkeypatch.setattr( + ParameterWizard, 'exec_', + staticmethod(lambda *args, **kwargs: ParameterWizard.Accepted) + ) + monkeypatch.setattr(ParameterWizard, 'selected', 1) + monkeypatch.setattr(ParameterWizard, 'param_data', param_data) + + assert bw.projects.current == "default" + assert "database_parameter_to_be_created" not in DatabaseParameter.load("activity_tests").keys() + + actions.ParameterNew(key, None).trigger() + + assert "database_parameter_to_be_created" in DatabaseParameter.load("activity_tests").keys() + + def test_parameter_new_activity(self, ab_app, monkeypatch): + key = ('activity_tests', '3fcde3e3bf424e97b32cf29347ac7f33') + group = "activity_group" + param_data = { + "name": "activity_parameter_to_be_created", + "database": key[0], + "code": key[1], + "group": group, + "amount": "1.0" + } + + monkeypatch.setattr( + ParameterWizard, 'exec_', + staticmethod(lambda *args, **kwargs: ParameterWizard.Accepted) + ) + monkeypatch.setattr(ParameterWizard, 'selected', 2) + monkeypatch.setattr(ParameterWizard, 'param_data', param_data) + + assert bw.projects.current == "default" + assert "activity_parameter_to_be_created" not in ActivityParameter.load(group).keys() + + actions.ParameterNew(key, None).trigger() + + assert "activity_parameter_to_be_created" in ActivityParameter.load(group).keys() + + def test_parameter_new_wizard_project(self, ab_app): + key = ("", "") + param_data = { + "name": "parameter_test", + "amount": "1.0" + } + wizard = ParameterWizard(key) + + assert not wizard.isVisible() + wizard.show() + assert wizard.isVisible() + assert wizard.pages[0].isVisible() + assert wizard.pages[0].selected == 0 + wizard.next() + assert wizard.pages[1].isVisible() + assert wizard.pages[1].database.isHidden() + wizard.pages[1].name.setText("parameter_test") + wizard.done(1) + assert not wizard.isVisible() + assert wizard.param_data == param_data + + def test_parameter_new_wizard_parameter(self, ab_app): + key = ("db", "") + param_data = { + "name": "parameter_test", + "database": "activity_tests", + "amount": "1.0" + } + wizard = ParameterWizard(key) + + assert not wizard.isVisible() + wizard.show() + assert wizard.isVisible() + assert wizard.pages[0].isVisible() + assert wizard.pages[0].selected == 1 + wizard.next() + assert wizard.pages[1].isVisible() + assert not wizard.pages[1].database.isHidden() + wizard.pages[1].name.setText("parameter_test") + wizard.done(1) + assert not wizard.isVisible() + assert wizard.param_data == param_data + + def test_parameter_new_wizard_activity(self, ab_app): + key = ('activity_tests', 'be8fb2776c354aa7ad61d8348828f3af') + param_data = { + "name": "parameter_test", + "database": "activity_tests", + "code": "be8fb2776c354aa7ad61d8348828f3af", + "group": "activity_22cfa9e9ef870ff4a93cbf5d3beff363", + "amount": "1.0" + } + wizard = ParameterWizard(key) + + assert not wizard.isVisible() + wizard.show() + assert wizard.isVisible() + assert wizard.pages[0].isVisible() + assert wizard.pages[0].selected == 2 + wizard.next() + assert wizard.pages[1].isVisible() + assert wizard.pages[1].database.isHidden() + wizard.pages[1].name.setText("parameter_test") + wizard.done(1) + assert not wizard.isVisible() + assert wizard.param_data == param_data + + +def test_parameter_new_automatic(ab_app): + key = ('activity_tests', 'be8fb2776c354aa7ad61d8348828f3af') + group = "activity_22cfa9e9ef870ff4a93cbf5d3beff363" + + assert bw.projects.current == "default" + assert "activity_1" not in ActivityParameter.load(group).keys() + + actions.ParameterNewAutomatic([key], None).trigger() + + assert "activity_1" in ActivityParameter.load(group).keys() + +def test_parameter_rename(ab_app, monkeypatch): + parameter = list(ProjectParameter.select().where(ProjectParameter.name == "parameter_to_rename"))[0] + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: ("renamed_parameter", True)) + ) + + assert bw.projects.current == "default" + assert "renamed_parameter" not in ProjectParameter.load().keys() + + actions.ParameterRename(parameter, None).trigger() + + assert "parameter_to_rename" not in ProjectParameter.load().keys() + assert "renamed_parameter" in ProjectParameter.load().keys() diff --git a/tests/actions/test_project_actions.py b/tests/actions/test_project_actions.py new file mode 100644 index 000000000..78acb1eca --- /dev/null +++ b/tests/actions/test_project_actions.py @@ -0,0 +1,84 @@ +import brightway2 as bw +from PySide2 import QtWidgets +from activity_browser import actions, project_controller, ab_settings +from activity_browser.ui.widgets import ProjectDeletionDialog + + +def test_project_delete(ab_app, monkeypatch): + project_name = "project_to_delete" + project_controller.new_project(project_name) + + monkeypatch.setattr( + ProjectDeletionDialog, 'exec_', + staticmethod(lambda *args, **kwargs: ProjectDeletionDialog.Accepted) + ) + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'information', + staticmethod(lambda *args, **kwargs: True) + ) + + assert bw.projects.current == project_name + + actions.ProjectDelete(None).trigger() + + assert bw.projects.current == ab_settings.startup_project + assert project_name not in bw.projects + + actions.ProjectDelete(None).trigger() + + assert bw.projects.current == ab_settings.startup_project + + +def test_project_duplicate(ab_app, monkeypatch): + project_name = "project_to_duplicate" + dup_project_name = "duplicated_project" + project_controller.new_project(project_name) + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: (dup_project_name, True)) + ) + monkeypatch.setattr( + QtWidgets.QMessageBox, 'information', + staticmethod(lambda *args, **kwargs: True) + ) + + assert bw.projects.current == project_name + assert dup_project_name not in bw.projects + + actions.ProjectDuplicate(None).trigger() + + assert bw.projects.current == dup_project_name + assert project_name in bw.projects + + projects_number = len(bw.projects) + + actions.ProjectDuplicate(None).trigger() + + assert len(bw.projects) == projects_number + + +def test_project_new(ab_app, monkeypatch): + project_name = "project_that_is_new" + + monkeypatch.setattr( + QtWidgets.QInputDialog, 'getText', + staticmethod(lambda *args, **kwargs: (project_name, True)) + ) + monkeypatch.setattr( + QtWidgets.QMessageBox, 'information', + staticmethod(lambda *args, **kwargs: True) + ) + + assert project_name not in bw.projects + + actions.ProjectNew(None).trigger() + + assert project_name in bw.projects + + projects_number = len(bw.projects) + + actions.ProjectNew(None).trigger() + + assert len(bw.projects) == projects_number diff --git a/tests/actions/test_various_actions.py b/tests/actions/test_various_actions.py new file mode 100644 index 000000000..06e5011a9 --- /dev/null +++ b/tests/actions/test_various_actions.py @@ -0,0 +1,85 @@ +import pytest +import os +import brightway2 as bw +from PySide2 import QtWidgets +from activity_browser import actions, project_controller, database_controller, signals +from activity_browser.ui.widgets import EcoinventVersionDialog + + +@pytest.mark.skipif(os.environ.get("TEST_FAST", False), reason="Skipped for faster testing") +def test_default_install(ab_app, monkeypatch, qtbot): + project_name = "biosphere_project" + project_controller.new_project(project_name) + + monkeypatch.setattr( + EcoinventVersionDialog, 'exec_', + staticmethod(lambda *args, **kwargs: EcoinventVersionDialog.Accepted) + ) + monkeypatch.setattr( + QtWidgets.QComboBox, 'currentText', + staticmethod(lambda *args, **kwargs: '3.7') + ) + + assert bw.projects.current == project_name + assert "biosphere3" not in bw.databases + + action = actions.DefaultInstall(None) + action.trigger() + + with qtbot.waitSignal(signals.databases_changed, timeout=5 * 60 * 1000): pass + + assert "biosphere3" in bw.databases + assert database_controller.record_count("biosphere3") == 4324 + assert len(bw.methods) == 762 + + +@pytest.mark.skipif(os.environ.get("TEST_FAST", False), reason="Skipped for faster testing") +def test_biosphere_update(ab_app, monkeypatch, qtbot): + project_name = "biosphere_project" + project_controller.change_project(project_name, reload=True) + + monkeypatch.setattr( + QtWidgets.QMessageBox, 'question', + staticmethod(lambda *args, **kwargs: QtWidgets.QMessageBox.Ok) + ) + monkeypatch.setattr( + EcoinventVersionDialog, 'exec_', + staticmethod(lambda *args, **kwargs: EcoinventVersionDialog.Accepted) + ) + monkeypatch.setattr( + QtWidgets.QComboBox, 'currentText', + staticmethod(lambda *args, **kwargs: '3.9.1') + ) + + assert bw.projects.current == project_name + assert "biosphere3" in bw.databases + assert database_controller.record_count("biosphere3") == 4324 + + action = actions.BiosphereUpdate(None) + action.trigger() + + with qtbot.waitSignal(action.updater.finished, timeout=5*60*1000): pass + + assert database_controller.record_count("biosphere3") == 4743 + + +def test_plugin_wizard_open(ab_app): + action = actions.PluginWizardOpen(None) + + with pytest.raises(AttributeError): assert not action.wizard.isVisible() + + action.trigger() + + assert action.wizard.isVisible() + + +def test_settings_wizard_open(ab_app): + action = actions.SettingsWizardOpen(None) + + with pytest.raises(AttributeError): assert not action.wizard.isVisible() + + action.trigger() + + assert action.wizard.isVisible() + + action.wizard.destroy() diff --git a/tests/conftest.py b/tests/conftest.py index 6d6e5ac6c..140465f64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,39 +1,27 @@ # -*- coding: utf-8 -*- import shutil +import os import brightway2 as bw +import bw2io as bi import pytest -from activity_browser import application, MainWindow +from activity_browser import application, MainWindow, project_controller @pytest.fixture(scope='session') -def ab_application(): +def ab_app(): """ Initialize the application and yield it. Cleanup the 'test' project after session is complete. """ + bw.projects._use_temp_directory() + bi.restore_project_directory(os.path.join(os.path.dirname(os.path.abspath(__file__)), "pytest_base.gz"), "default", overwrite_existing=True) + application.main_window = MainWindow(application) + application.show() + project_controller.change_project("default", True) yield application - # Explicitly close the window application.close() - # Explicitly close the connection to all the databases for the pytest_project - if bw.projects.current == "pytest_project": - for _, db in bw.config.sqlite3_databases: - if not db._database.is_closed(): - db._database.close() - if 'pytest_project' in bw.projects: - bw.projects.delete_project('pytest_project', delete_dir=True) - # finally, perform a cleanup of any remnants, mostly for local testing - bw.projects.purge_deleted_directories() - - -@pytest.fixture() -def ab_app(qtbot, ab_application): - """ Function-level fixture which returns the session-level application. - This is the actual fixture to be used in tests. - """ - ab_application.show() - return ab_application @pytest.fixture() diff --git a/tests/test_settings.py b/tests/legacy/test_settings.py similarity index 84% rename from tests/test_settings.py rename to tests/legacy/test_settings.py index 57d8008bb..1a9be4b66 100644 --- a/tests/test_settings.py +++ b/tests/legacy/test_settings.py @@ -29,7 +29,7 @@ def project_settings(qtbot, ab_app): def test_base_class(): - """ Test that the base class raises an error on initialization + """ Test that the base.py class raises an error on initialization """ current_path = os.path.dirname(os.path.abspath(__file__)) with pytest.raises(NotImplementedError): @@ -54,17 +54,6 @@ def test_ab_edit_settings(ab_settings): assert ab_settings.custom_bw_dir != ABSettings.get_default_directory() -@pytest.mark.skipif("pytest_project" not in bw.projects, reason="test project not created") -def test_ab_existing_startup(ab_settings): - """ Alter the startup project and assert that it is correctly changed. - - Will be skipped if test_settings.py is run in isolation because the test - project has not been created (results in duplicate of test below) - """ - ab_settings.startup_project = "pytest_project" - assert ab_settings.startup_project != ABSettings.get_default_project_name() - - def test_ab_unknown_startup(ab_settings): """ Alter the startup project with an unknown project, assert that it was not altered because the project does not exist. diff --git a/tests/test_uncertainty.py b/tests/legacy/test_uncertainty.py similarity index 96% rename from tests/test_uncertainty.py rename to tests/legacy/test_uncertainty.py index 4e948b6da..8caf7dd0f 100644 --- a/tests/test_uncertainty.py +++ b/tests/legacy/test_uncertainty.py @@ -8,6 +8,7 @@ import pytest from stats_arrays.distributions import UndefinedUncertainty, UniformUncertainty +from activity_browser import project_controller from activity_browser.bwutils.uncertainty import ( ExchangeUncertaintyInterface, CFUncertaintyInterface, get_uncertainty_interface ) @@ -15,6 +16,7 @@ from activity_browser.ui.tables.parameters import ProjectParameterTable def test_exchange_interface(qtbot, ab_app): + project_controller.change_project("default") flow = bw.Database(bw.config.biosphere).random() db = bw.Database("testdb") act_key = ("testdb", "act_unc") diff --git a/tests/test_utils.py b/tests/legacy/test_utils.py similarity index 100% rename from tests/test_utils.py rename to tests/legacy/test_utils.py diff --git a/tests/test_widgets.py b/tests/legacy/test_widgets.py similarity index 65% rename from tests/test_widgets.py rename to tests/legacy/test_widgets.py index 7a1641f11..e0473f756 100644 --- a/tests/test_widgets.py +++ b/tests/legacy/test_widgets.py @@ -41,30 +41,6 @@ def test_comparison_switch_all(qtbot): box.configure() size = box.count() assert size == 3 - # assert box.isVisible() # Box fails to be visible, except it definitely is? - -#Outdated doesnt work with the new update -# def test_cutoff_menu_relative(qtbot): -# """ Simple check of all the slots on the CutoffMenu class -# """ -# slider = CutoffMenu() -# qtbot.addWidget(slider) -# assert slider.cutoff_value == 0.01 -# assert slider.is_relative -# -# assert slider.sliders.relative.value() == 20 -# assert slider.sliders.relative.log_value == 1.8 -# qtbot.mouseClick(slider.cutoff_slider_lft_btn, Qt.LeftButton) -# assert slider.sliders.relative.value() == 21 -# assert slider.sliders.relative.log_value == 2.0 -# qtbot.mouseClick(slider.cutoff_slider_rght_btn, Qt.LeftButton) -# assert slider.sliders.relative.value() == 20 -# assert slider.sliders.relative.log_value == 1.8 -# -# with qtbot.waitSignal(slider.slider_change, timeout=1600): -# slider.cutoff_slider_line.setText("0.1") -# assert slider.sliders.relative.value() == 40 -# assert slider.sliders.relative.log_value == 10 def test_cutoff_slider_toggle(qtbot): @@ -76,22 +52,6 @@ def test_cutoff_slider_toggle(qtbot): assert slider.limit_type == "number" -# def test_cutoff_slider_top(qtbot): -# slider = CutoffMenu() -# qtbot.addWidget(slider) -# slider.buttons.topx.click() -# -# assert slider.sliders.topx.value() == 1 -# qtbot.mouseClick(slider.cutoff_slider_rght_btn, Qt.LeftButton) -# assert slider.sliders.topx.value() == 2 -# qtbot.mouseClick(slider.cutoff_slider_lft_btn, Qt.LeftButton) -# assert slider.sliders.topx.value() == 1 -# -# with qtbot.waitSignal(slider.slider_change, timeout=1600): -# slider.cutoff_slider_line.setText("15") -# assert slider.sliders.topx.value() == 15 - - def test_input_dialog(qtbot): """ Test the various thing about the dialog widget. """ diff --git a/tests/pytest_base.gz b/tests/pytest_base.gz new file mode 100644 index 000000000..88b2bbe01 Binary files /dev/null and b/tests/pytest_base.gz differ diff --git a/tests/test_add_default_data.py b/tests/test_add_default_data.py deleted file mode 100644 index d03aa1b3f..000000000 --- a/tests/test_add_default_data.py +++ /dev/null @@ -1,146 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from PySide2 import QtCore, QtWidgets - -from activity_browser.signals import signals -from activity_browser.ui.widgets.dialog import EcoinventVersionDialog - - -def test_add_default_data(qtbot, ab_app, monkeypatch): - """Switch to 'pytest_project' and add default data.""" - assert bw.projects.current == 'default' - qtbot.waitExposed(ab_app.main_window) - - # fake the project name text input when called - monkeypatch.setattr( - QtWidgets.QInputDialog, 'getText', - staticmethod(lambda *args, **kwargs: ('pytest_project', True)) - ) - project_tab = ab_app.main_window.left_panel.tabs['Project'] - qtbot.mouseClick( - project_tab.projects_widget.new_project_button, - QtCore.Qt.LeftButton - ) - assert bw.projects.current == 'pytest_project' - - # The biosphere3 import finishes with a 'change_project' signal. - with qtbot.waitSignal(signals.change_project, timeout=10*60*1000): # allow 10 mins for biosphere install - - # fake the accepting of the dialog when started - monkeypatch.setattr(EcoinventVersionDialog, 'exec_', lambda self: EcoinventVersionDialog.Accepted) - - # click the 'add default data' button - qtbot.mouseClick( - project_tab.databases_widget.add_default_data_button, - QtCore.Qt.LeftButton - ) - - # The biosphere3 update finishes with a 'database_changed' signal. - with qtbot.waitSignal(signals.database_changed, timeout=2 * 60 * 1000): # allow 2 mins for biosphere update - pass - - # biosphere was installed - assert 'biosphere3' in bw.databases - - -def test_select_biosphere(qtbot, ab_app): - """Select the 'biosphere3' database from the databases table.""" - biosphere = 'biosphere3' - project_tab = ab_app.main_window.left_panel.tabs['Project'] - db_table = project_tab.databases_widget.table - dbs = [ - db_table.model.index(i, 0).data() for i in range(db_table.rowCount()) - ] - assert biosphere in dbs - act_bio_tabs = project_tab.activity_biosphere_tabs - act_bio_tabs.open_or_focus_tab(biosphere) - act_bio_widget = act_bio_tabs.tabs[biosphere] - - # Grab the rectangle of the 2nd column on the first row. - rect = db_table.visualRect(db_table.proxy_model.index(0, 1)) - with qtbot.waitSignal(signals.database_selected, timeout=1000): - # Click once to 'focus' the table - qtbot.mouseClick(db_table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - # Then double-click to trigger the `doubleClick` event. - qtbot.mouseDClick(db_table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - - assert act_bio_widget.table.rowCount() > 0 - - -def test_search_biosphere(qtbot, ab_app): - assert bw.projects.current == 'pytest_project' - project_tab = ab_app.main_window.left_panel.tabs['Project'] - - act_bio_tabs = project_tab.activity_biosphere_tabs - act_bio_tabs.open_or_focus_tab('biosphere3') - act_bio_widget = act_bio_tabs.tabs['biosphere3'] - - initial_amount = act_bio_widget.table.rowCount() - # Now search for a specific string - with qtbot.waitSignal(act_bio_widget.search_box.returnPressed, timeout=1000): - qtbot.keyClicks(act_bio_widget.search_box, 'Pentanol') - qtbot.keyPress(act_bio_widget.search_box, QtCore.Qt.Key_Return) - - # Reset search box & timer so it doesn't auto-search after this test - act_bio_widget.search_box.clear() - act_bio_widget.debounce_search.stop() - # We found some results! - assert act_bio_widget.table.rowCount() > 0 - # And the table is now definitely smaller than it was. - assert act_bio_widget.table.rowCount() < initial_amount - - - -def test_fail_open_biosphere(ab_app): - """Specifically fail to open an activity tab for a biosphere flow.""" - assert bw.projects.current == 'pytest_project' - activities_tab = ab_app.main_window.right_panel.tabs['Activity Details'] - # Select any biosphere activity and emit signal to trigger opening the tab - biosphere_flow = bw.Database('biosphere3').random() - signals.safe_open_activity_tab.emit(biosphere_flow.key) - assert len(activities_tab.tabs) == 0 - - -def test_succceed_open_activity(ab_app): - """Create a tiny test database with a production activity.""" - assert bw.projects.current == 'pytest_project' - db = bw.Database('testdb') - act_key = ('testdb', 'act1') - db.write({ - act_key: { - 'name': 'act1', - 'unit': 'kilogram', - 'exchanges': [ - {'input': act_key, 'amount': 1, 'type': 'production'} - ] - } - }) - activities_tab = ab_app.main_window.right_panel.tabs['Activity Details'] - # Select the activity and emit signal to trigger opening the tab - act = bw.get_activity(act_key) - signals.safe_open_activity_tab.emit(act_key) - assert len(activities_tab.tabs) == 1 - assert act_key in activities_tab.tabs - # Current index of QTabWidget is changed by opening the tab - index = activities_tab.currentIndex() - assert act.get('name') == activities_tab.tabText(index) - - -def test_close_open_activity_tab(ab_app): - """Closing the activity tab will also hide the Activity Details tab.""" - act_key = ('testdb', 'act1') - act = bw.get_activity(act_key) - act_name = act.get('name') - activities_tab = ab_app.main_window.right_panel.tabs['Activity Details'] - - # The tab should still be open from the previous test - assert act_key in activities_tab.tabs - index = activities_tab.currentIndex() - assert act_name == activities_tab.tabText(index) - - # Now close the tab. - activities_tab.close_tab_by_tab_name(act_key) - # Check that the tab no longer exists, and that the activity details tab - # is hidden. - assert act_key not in activities_tab.tabs - assert activities_tab.isHidden() diff --git a/tests/test_calculation_setup.py b/tests/test_calculation_setup.py deleted file mode 100644 index f2c4b5d15..000000000 --- a/tests/test_calculation_setup.py +++ /dev/null @@ -1,45 +0,0 @@ -import brightway2 as bw -from PySide2 import QtCore, QtWidgets - -def test_new_calculation_setup(qtbot, ab_app, monkeypatch): - assert bw.projects.current == 'pytest_project' - - monkeypatch.setattr( - QtWidgets.QInputDialog, 'getText', - staticmethod(lambda *args, **kwargs: ('pytest_cs', True)) - ) - - cs_tab = ab_app.main_window.right_panel.tabs["LCA Setup"] - qtbot.mouseClick( - cs_tab.new_cs_button, - QtCore.Qt.LeftButton - ) - - assert len(bw.calculation_setups) == 1 - assert "pytest_cs" in bw.calculation_setups - -def test_delete_calculation_setup(qtbot, ab_app, monkeypatch): - assert bw.projects.current == 'pytest_project' - assert len(bw.calculation_setups) == 1 - - monkeypatch.setattr( - QtWidgets.QMessageBox, 'warning', - lambda *args, **kwargs: QtWidgets.QMessageBox.Yes - ) - monkeypatch.setattr( - QtWidgets.QMessageBox, 'information', - lambda *args, **kwargs: True - ) - - cs_tab = ab_app.main_window.right_panel.tabs["LCA Setup"] - - assert cs_tab.list_widget.name == 'pytest_cs' - - qtbot.mouseClick( - cs_tab.delete_cs_button, - QtCore.Qt.LeftButton - ) - - - assert len(bw.calculation_setups) == 0 - assert "pytest_cs" not in bw.calculation_setups \ No newline at end of file diff --git a/tests/test_import_wizard.py b/tests/test_import_wizard.py deleted file mode 100644 index 127649fdb..000000000 --- a/tests/test_import_wizard.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from PySide2 import QtCore, QtWidgets - -from activity_browser.signals import signals -from activity_browser.controllers.database import DatabaseController -from activity_browser.ui.wizards.db_import_wizard import DatabaseImportWizard - - -def test_open_db_wizard_button(qtbot, ab_app, monkeypatch): - """Show that the signals and slots works for importing.""" - assert bw.projects.current == 'pytest_project' - qtbot.waitForWindowShown(ab_app.main_window) - project_tab = ab_app.main_window.left_panel.tabs['Project'] - - # Monkeypatch the 'import_database_wizard' method in the controller - monkeypatch.setattr( - DatabaseController, "import_database_wizard", lambda *args: None - ) - with qtbot.waitSignal(signals.import_database, timeout=500): - qtbot.mouseClick( - project_tab.databases_widget.import_database_button, - QtCore.Qt.LeftButton - ) - - -def test_open_db_wizard(qtbot, ab_app): - """Open the wizard itself.""" - qtbot.waitForWindowShown(ab_app.main_window) - wizard = DatabaseImportWizard(ab_app.main_window) - qtbot.addWidget(wizard) - wizard.show() - - qtbot.mouseClick( - wizard.button(QtWidgets.QWizard.CancelButton), - QtCore.Qt.LeftButton - ) diff --git a/tests/test_parameters.py b/tests/test_parameters.py deleted file mode 100644 index 40d1ed52a..000000000 --- a/tests/test_parameters.py +++ /dev/null @@ -1,341 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from bw2data.parameters import (ActivityParameter, DatabaseParameter, Group, - ProjectParameter) -from PySide2 import QtCore, QtWidgets -import pytest - -from activity_browser.signals import signals -from activity_browser.ui.tables.delegates import FormulaDelegate -from activity_browser.ui.tables.parameters import ( - ActivityParameterTable, DataBaseParameterTable, - ProjectParameterTable -) -from activity_browser.layouts.tabs.parameters import ParameterDefinitionTab - - -def test_create_project_param(qtbot): - """ Create a single Project parameter. - - Does not user the overarching application due to mouseClick failing - """ - assert bw.projects.current == "pytest_project" - assert ProjectParameter.select().count() == 0 - - project_db_tab = ParameterDefinitionTab() - qtbot.addWidget(project_db_tab) - project_db_tab.build_tables() - table = project_db_tab.project_table.get_table() - - bw.parameters.new_project_parameters([ - {"name": "param_1", "amount": 1.0}, - {"name": "param_2", "amount": 1.0}, - {"name": "param_3", "amount": 1.0}, - ]) - table.model.sync() - assert table.rowCount() == 3 - - # New parameter is named 'param_1' - assert table.model.index(0, 0).data() == "param_1" - assert ProjectParameter.select().count() == 3 - assert ProjectParameter.select().where(ProjectParameter.name == "param_1").exists() - - -def test_edit_project_param(qtbot, monkeypatch): - """ Edit the existing parameter to have new values. - """ - table = ProjectParameterTable() - qtbot.addWidget(table) - table.model.sync() - - # Edit both the name and the amount of the first parameter. - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("test_project", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(0, 0)) - table.model.setData(table.model.index(0, 1), 2.5) - - # Check that parameter is correctly stored in brightway. - assert ProjectParameter.get(name="test_project").amount == 2.5 - - # Now edit the formula directly (without delegate) - with qtbot.waitSignal(signals.parameters_changed, timeout=1000): - table.model.setData(table.model.index(0, 2), "2 + 3") - assert ProjectParameter.get(name="test_project").amount == 5 - - # Now edit the formula of the 3rd param to use the 2nd param - with qtbot.waitSignal(signals.parameters_changed, timeout=1000): - table.model.setData(table.model.index(2, 2), "param_2 + 3") - assert ProjectParameter.get(name="param_3").amount == 4 - - -def test_delete_project_param(qtbot): - """ Try and delete project parameters through the tab. - """ - table = ProjectParameterTable() - qtbot.addWidget(table) - table.model.sync() - - # The 2nd parameter cannot be deleted - param = table.get_parameter(table.proxy_model.index(1, 0)) - assert not param.is_deletable() - - # Delete the 3rd parameter, removing the dependency - table.delete_parameter(table.proxy_model.index(2, 0)) - - # 2nd parameter can now be deleted, so delete it. - assert param.is_deletable() - table.delete_parameter(table.proxy_model.index(1, 0)) - - -def test_create_database_params(qtbot): - """ Create three database parameters - - Does not user the overarching application due to mouseClick failing - """ - assert DatabaseParameter.select().count() == 0 - - project_db_tab = ParameterDefinitionTab() - qtbot.addWidget(project_db_tab) - project_db_tab.build_tables() - table = project_db_tab.database_table.get_table() - - # Open the database foldout - assert not project_db_tab.database_table.isHidden() - with qtbot.waitSignal(project_db_tab.show_database_params.stateChanged, timeout=1000): - qtbot.mouseClick(project_db_tab.show_database_params, QtCore.Qt.LeftButton) - assert project_db_tab.database_table.isHidden() - project_db_tab.show_database_params.toggle() - - # Generate a few database parameters - bw.parameters.new_database_parameters([ - {"name": "param_2", "amount": 1.0}, - {"name": "param_3", "amount": 1.0}, - {"name": "param_4", "amount": 1.0}, - ], database="biosphere3") - table.model.sync() - - # First created parameter is named 'param_2' - assert table.model.index(0, 0).data() == "param_2" - assert table.rowCount() == 3 - assert DatabaseParameter.select().count() == 3 - - -def test_edit_database_params(qtbot, monkeypatch): - table = DataBaseParameterTable() - qtbot.addWidget(table) - table.model.sync() - - # Fill rows with new variables - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("test_db1", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(0, 0)) - table.model.setData(table.model.index(0, 2), "test_project + 3.5") - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("test_db2", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(1, 0)) - table.model.setData(table.model.index(1, 2), "test_db1 ** 2") - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("test_db3", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(2, 0)) - table.model.setData(table.model.index(2, 1), "8.5") - table.model.setData(table.model.index(2, 3), "testdb") - - # 5 + 3.5 = 8.5 -> 8.5 ** 2 = 72.25 - assert DatabaseParameter.get(name="test_db2").amount == 72.25 - # There are two parameters for `biosphere3` and one for `testdb` - assert (DatabaseParameter.select() - .where(DatabaseParameter.database == "biosphere3").count()) == 2 - assert (DatabaseParameter.select() - .where(DatabaseParameter.database == "testdb").count()) == 1 - - -def test_delete_database_params(qtbot): - """ Attempt to delete a parameter. - """ - project_db_tab = ParameterDefinitionTab() - qtbot.addWidget(project_db_tab) - project_db_tab.build_tables() - table = project_db_tab.database_table.get_table() - - # Check that we can delete the parameter and remove it. - proxy = table.proxy_model.index(1, 0) - assert table.get_parameter(proxy).is_deletable() - table.delete_parameter(proxy) - - # Now we have two rows left - assert table.rowCount() == 2 - assert DatabaseParameter.select().count() == 2 - - -def test_downstream_dependency(qtbot): - """ A database parameter uses a project parameter in its formula. - - Means we can't delete it right? - """ - table = ProjectParameterTable() - qtbot.addWidget(table) - table.model.sync() - - # First parameter of the project table is used by the database parameter - param = table.get_parameter(table.proxy_model.index(0, 0)) - assert not param.is_deletable() - - -def test_create_activity_param(qtbot): - """ Create several activity parameters. - - TODO: Figure out some way of performing a drag action between tables. - Use method calls for now. - Until the above is implemented, take shortcuts and don't check db validity - """ - project_db_tab = ParameterDefinitionTab() - qtbot.addWidget(project_db_tab) - project_db_tab.build_tables() - table = project_db_tab.activity_table.get_table() - - # Open the order column just because we can - col = table.model.order_col - assert table.isColumnHidden(col) - with qtbot.waitSignal(project_db_tab.activity_table.parameter.stateChanged, timeout=1000): - qtbot.mouseClick(project_db_tab.activity_table.parameter, QtCore.Qt.LeftButton) - assert not table.isColumnHidden(col) - - # Create multiple parameters for a single activity - act_key = ("testdb", "act1") - for _ in range(3): - table.model.add_parameter(act_key) - - # Test created parameters - assert ActivityParameter.select().count() == 3 - # First of the multiple parameters - assert table.proxy_model.index(0, 0).data() == "act_1" - # Second of the multiple parameters - assert table.proxy_model.index(1, 0).data() == "act_2" - # The group name for the `testdb` parameters is the same. - loc = table.visualRect(table.proxy_model.index(0, 0)) - qtbot.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=loc.center()) - group = table.get_current_group() - assert table.proxy_model.index(2, table.model.group_col).data() == group - - -def test_edit_activity_param(qtbot, monkeypatch): - """ Alter names, amounts and formulas. - - Introduce dependencies through formulas - """ - table = ActivityParameterTable() - qtbot.addWidget(table) - table.model.sync() - - # Fill rows with new variables - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("edit_act_1", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(0, 0)) - table.model.setData(table.model.index(0, 2), "test_db3 * 3") - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", staticmethod(lambda *args, **kwargs: ("edit_act_2", True)) - ) - table.model.handle_parameter_rename(table.proxy_model.index(1, 0)) - table.model.setData(table.model.index(1, 2), "edit_act_1 - 3") - - # Test updated values - assert ActivityParameter.get(name="edit_act_1").amount == 25.5 - assert ActivityParameter.get(name="edit_act_2").amount == 22.5 - - -def test_activity_order_edit(qtbot): - table = ActivityParameterTable() - qtbot.addWidget(table) - table.model.sync() - group = table.model.index(0, table.model.group_col).data() - with qtbot.waitSignal(signals.parameters_changed, timeout=1000): - table.model.setData(table.model.index(0, 5), [group]) - - -@pytest.mark.parametrize( - "table_class", [ - ProjectParameterTable, - DataBaseParameterTable, - ActivityParameterTable, - ] -) -def test_table_formula_delegates(qtbot, table_class): - """ Open the formula delegate to test all related methods within the table. - """ - table = table_class() - qtbot.addWidget(table) - table.model.sync() - - assert isinstance(table.itemDelegateForColumn(2), FormulaDelegate) - - delegate = FormulaDelegate(table) - option = QtWidgets.QStyleOptionViewItem() - option.rect = QtCore.QRect(0, 0, 100, 100) - index = table.proxy_model.index(0, 2) - # Note: ActivityParameterTable depends on the user having clicked - # the table to correctly extract the group for the activity - rect = table.visualRect(index) - qtbot.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - editor = delegate.createEditor(table, option, index) - qtbot.addWidget(editor) - delegate.setEditorData(editor, index) - delegate.setModelData(editor, table.proxy_model, index) - - -def test_open_activity_tab(qtbot, ab_app): - """ Trigger an 'open tab and switch to' action for a parameter. - """ - # First, look at the parameters tab - panel = ab_app.main_window.right_panel - param_tab = panel.tabs["Parameters"] - activities_tab = panel.tabs["Activity Details"] - ab_app.main_window.right_panel.select_tab(param_tab) - - # Select an activity - tab = param_tab.tabs["Definitions"] - table = tab.activity_table.get_table() - rect = table.visualRect(table.proxy_model.index(0, 3)) - qtbot.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - - # Trigger the tab to open - table.open_activity_tab() - - # We should now be looking at the activity tab - assert panel.currentIndex() != panel.indexOf(param_tab) - assert panel.currentIndex() == panel.indexOf(activities_tab) - - # And close the tab again. - activities_tab.close_all() - - -def test_delete_activity_param(qtbot): - """ Remove activity parameters. - """ - project_db_tab = ParameterDefinitionTab() - qtbot.addWidget(project_db_tab) - project_db_tab.build_tables() - table = project_db_tab.activity_table.get_table() - - rect = table.visualRect(table.proxy_model.index(0, 0)) - qtbot.mouseClick(table.viewport(), QtCore.Qt.LeftButton, pos=rect.center()) - - group = table.get_current_group() - - # Now delete the parameter for the selected row. - table.delete_parameter(table.currentIndex()) - assert table.rowCount() == 2 - assert ActivityParameter.select().count() == 2 - assert Group.select().where(Group.name == group).exists() - - # And delete the other two parameters one by one. - table.delete_parameter(table.proxy_model.index(0, 0)) - table.delete_parameter(table.proxy_model.index(0, 0)) - assert table.rowCount() == 0 - assert ActivityParameter.select().count() == 0 - # Group is automatically removed with the last parameter gone - assert not Group.select().where(Group.name == group).exists() diff --git a/tests/test_projects.py b/tests/test_projects.py deleted file mode 100644 index 233302406..000000000 --- a/tests/test_projects.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from PySide2 import QtCore, QtWidgets - -from activity_browser.ui.widgets.dialog import ProjectDeletionDialog - - -def test_new_project(qtbot, ab_app, monkeypatch): - qtbot.waitForWindowShown(ab_app.main_window) - monkeypatch.setattr( - QtWidgets.QInputDialog, "getText", - staticmethod(lambda *args, **kwargs: ("pytest_project_del", True)) - ) - project_tab = ab_app.main_window.left_panel.tabs['Project'] - qtbot.mouseClick( - project_tab.projects_widget.new_project_button, - QtCore.Qt.LeftButton - ) - assert bw.projects.current == 'pytest_project_del' - - -def test_change_project(qtbot, ab_app): - qtbot.waitForWindowShown(ab_app.main_window) - assert bw.projects.current == 'pytest_project_del' - project_tab = ab_app.main_window.left_panel.tabs['Project'] - combobox = project_tab.projects_widget.projects_list - assert 'default' in bw.projects - assert 'default' in combobox.project_names - combobox.activated.emit(combobox.project_names.index('default')) - assert bw.projects.current == 'default' - combobox.activated.emit(combobox.project_names.index('pytest_project_del')) - assert bw.projects.current == 'pytest_project_del' - - -def test_delete_project(qtbot, ab_app, monkeypatch): - qtbot.waitForWindowShown(ab_app.main_window) - assert bw.projects.current == 'pytest_project_del' - monkeypatch.setattr( - ProjectDeletionDialog, "exec_", - staticmethod(lambda *args: ProjectDeletionDialog.Accepted) - ) - monkeypatch.setattr( - ProjectDeletionDialog, "deletion_warning_checked", - staticmethod(lambda *args: True) - ) - monkeypatch.setattr( - QtWidgets.QMessageBox, "information", - staticmethod(lambda *args: True) - ) - project_tab = ab_app.main_window.left_panel.tabs['Project'] - qtbot.mouseClick( - project_tab.projects_widget.delete_project_button, - QtCore.Qt.LeftButton - ) - - assert bw.projects.current == 'default' \ No newline at end of file diff --git a/tests/test_settings_wizard.py b/tests/test_settings_wizard.py deleted file mode 100644 index d6934bc55..000000000 --- a/tests/test_settings_wizard.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8 -*- -import brightway2 as bw -from PySide2.QtCore import Qt -from PySide2.QtWidgets import QMessageBox, QWizard - -from activity_browser.ui.wizards.settings_wizard import SettingsWizard -from activity_browser.settings import ab_settings - -def test_settings_wizard_simple(qtbot, bw2test): - """Test some of the default values of the wizard.""" - wizard = SettingsWizard(None) - qtbot.addWidget(wizard) - wizard.show() - - # Check that the default fields are default - assert wizard.field("startup_project") == "default" - assert wizard.field("current_bw_dir") == ab_settings.current_bw_dir - assert wizard.last_bwdir == bw.projects._base_data_dir - - # We can't click 'Save' from the start. - assert not wizard.button(QWizard.FinishButton).isEnabled() - - # cancel out of the wizard. - qtbot.mouseClick(wizard.button(QWizard.CancelButton), Qt.LeftButton) - - -def test_alter_startup_project(qtbot): - """Alter the default startup project""" - wizard = SettingsWizard(None) - qtbot.addWidget(wizard) - wizard.show() - - # Check we can't Save, alter the startup project and check again. - assert not wizard.settings_page.isComplete() - with qtbot.waitSignal(wizard.settings_page.completeChanged, timeout=100): - index = wizard.settings_page.project_names.index("pytest_project") - wizard.settings_page.startup_project_combobox.setCurrentIndex(index) - assert wizard.field("startup_project") == "pytest_project" - assert wizard.settings_page.isComplete() - - with qtbot.waitSignal(wizard.finished, timeout=100): - qtbot.mouseClick(wizard.button(QWizard.FinishButton), Qt.LeftButton) - - -def test_restore_defaults(qtbot, monkeypatch): - """Restore the default startup project.""" - wizard = SettingsWizard(None) - qtbot.addWidget(wizard) - wizard.show() - - # Follow-up from the last test, restore the startup_project to default - assert wizard.field("startup_project") == "pytest_project" - - with qtbot.waitSignal(wizard.settings_page.startup_project_combobox.currentIndexChanged, timeout=100): - # No handle the popup about changing the brightway2 directory. - monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.No) - qtbot.mouseClick( - wizard.settings_page.restore_defaults_button, - Qt.LeftButton - ) - - assert wizard.field("startup_project") == "default" - - with qtbot.waitSignal(wizard.finished, timeout=100): - qtbot.mouseClick(wizard.button(QWizard.FinishButton), Qt.LeftButton) diff --git a/tests/test_export_wizard.py b/tests/wizards/test_export_wizard.py similarity index 59% rename from tests/test_export_wizard.py rename to tests/wizards/test_export_wizard.py index 031187680..ec7a36976 100644 --- a/tests/test_export_wizard.py +++ b/tests/wizards/test_export_wizard.py @@ -9,22 +9,22 @@ # TODO: Add fixture with small database to export. -def test_trigger_export_wizard(qtbot, ab_app, monkeypatch): - """Test the triggers for the export wizard.""" - assert bw.projects.current == 'pytest_project' - qtbot.waitForWindowShown(ab_app.main_window) - - menu_bar = ab_app.main_window.menu_bar - - monkeypatch.setattr( - DatabaseController, "export_database_wizard", lambda *args: None - ) - # Trigger the action for export database. - with qtbot.waitSignal(signals.export_database, timeout=500): - menu_bar.export_db_action.trigger() - - -def test_open_export_wizard(qtbot, ab_app): +# def test_trigger_export_wizard(qtbot, ab_app, monkeypatch): +# """Test the triggers for the export wizard.""" +# assert bw.projects.current == 'pytest_project' +# qtbot.waitForWindowShown(ab_app.main_window) +# +# menu_bar = ab_app.main_window.menu_bar +# +# monkeypatch.setattr( +# DatabaseController, "export_database_wizard", lambda *args: None +# ) +# # Trigger the action for export database. +# with qtbot.waitSignal(signals.export_database, timeout=500): +# menu_bar.export_db_action.trigger() + + +def test_open_export_wizard(ab_app, qtbot): """Actually open the export wizard.""" qtbot.waitForWindowShown(ab_app.main_window) wizard = DatabaseExportWizard(ab_app.main_window) diff --git a/tests/wizards/test_import_wizard.py b/tests/wizards/test_import_wizard.py new file mode 100644 index 000000000..62a7bb64a --- /dev/null +++ b/tests/wizards/test_import_wizard.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import brightway2 as bw +from PySide2 import QtCore, QtWidgets + +from activity_browser.signals import signals +from activity_browser.controllers.database import DatabaseController +from activity_browser.ui.wizards.db_import_wizard import DatabaseImportWizard + +# +# +# def test_open_db_wizard_button(qtbot, ab_app, monkeypatch): +# """Show that the signals and slots works for importing.""" +# assert bw.projects.current == 'pytest_project' +# qtbot.waitForWindowShown(ab_app.main_window) +# project_tab = ab_app.main_window.left_panel.tabs['Project'] +# +# # Monkeypatch the 'import_database_wizard' method in the controller +# monkeypatch.setattr( +# DatabaseController, "import_database_wizard", lambda *args: None +# ) +# with qtbot.waitSignal(signals.import_database, timeout=500): +# qtbot.mouseClick( +# project_tab.databases_widget.import_database_button, +# QtCore.Qt.LeftButton +# ) + + +def test_open_db_wizard(ab_app, qtbot): + """Open the wizard itself.""" + qtbot.waitForWindowShown(ab_app.main_window) + wizard = DatabaseImportWizard(ab_app.main_window) + qtbot.addWidget(wizard) + wizard.show() + + qtbot.mouseClick( + wizard.button(QtWidgets.QWizard.CancelButton), + QtCore.Qt.LeftButton + ) diff --git a/tests/test_uncertainty_wizard.py b/tests/wizards/test_uncertainty_wizard.py similarity index 95% rename from tests/test_uncertainty_wizard.py rename to tests/wizards/test_uncertainty_wizard.py index 2c44972fc..4db260ad3 100644 --- a/tests/test_uncertainty_wizard.py +++ b/tests/wizards/test_uncertainty_wizard.py @@ -2,7 +2,7 @@ import logging import sys - +import brightway2 as bw from bw2data.parameters import ProjectParameter import numpy as np from PySide2.QtWidgets import QMessageBox, QWizard @@ -27,7 +27,7 @@ log.propagate = True @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_wizard_fail(qtbot): +def test_wizard_fail(ab_app, qtbot): """Can't create a wizard if no uncertainty interface exists.""" mystery_box = ["Hello", "My", "Name", "Is", "Error"] # Type is list. with pytest.raises(TypeError): @@ -35,7 +35,7 @@ def test_wizard_fail(qtbot): @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_uncertainty_wizard_simple(qtbot, bw2test, caplog): +def test_uncertainty_wizard_simple(ab_app, qtbot, caplog): """Use extremely simple text to open the wizard and go to all the pages.""" caplog.set_level(logging.INFO) param = ProjectParameter.create(name="test1", amount=3) @@ -62,12 +62,12 @@ def test_uncertainty_wizard_simple(qtbot, bw2test, caplog): @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_graph_rebuild(qtbot, bw2test): +def test_graph_rebuild(ab_app, qtbot): """Test that the graph is correctly built and rebuilt, ensure that the 'finish' button is enabled and disabled at the correct times. """ - param = ProjectParameter.create(name="test1", amount=3) + param = ProjectParameter.create(name="test2", amount=3) wizard = UncertaintyWizard(param, None) qtbot.addWidget(wizard) wizard.show() @@ -97,7 +97,7 @@ def test_graph_rebuild(qtbot, bw2test): @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_update_uncertainty(qtbot, ab_app): +def test_update_uncertainty(ab_app, qtbot): """Using the signal/controller setup, update the uncertainty of a parameter""" param = ProjectParameter.create(name="uc1", amount=3) wizard = UncertaintyWizard(param, None) @@ -148,7 +148,7 @@ def test_update_alter_mean(qtbot, monkeypatch, ab_app): @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_lognormal_mean_balance(qtbot, bw2test): +def test_lognormal_mean_balance(qtbot, bw2test, ab_app): uncertain = { "loc": 2, "scale": 0.2, @@ -182,7 +182,7 @@ def test_lognormal_mean_balance(qtbot, bw2test): @pytest.mark.skipif(sys.platform=='darwin', reason="tests segfaults on osx") -def test_pedigree(qtbot, bw2test): +def test_pedigree(qtbot, bw2test, ab_app): """Configure uncertainty using the pedigree page of the wizard.""" uncertain = { "loc": 2,