ModelerDialog.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. """
  2. ***************************************************************************
  3. ModelerDialog.py
  4. ---------------------
  5. Date : August 2012
  6. Copyright : (C) 2012 by Victor Olaya
  7. Email : volayaf at gmail dot com
  8. ***************************************************************************
  9. * *
  10. * This program is free software; you can redistribute it and/or modify *
  11. * it under the terms of the GNU General Public License as published by *
  12. * the Free Software Foundation; either version 2 of the License, or *
  13. * (at your option) any later version. *
  14. * *
  15. ***************************************************************************
  16. """
  17. __author__ = 'Victor Olaya'
  18. __date__ = 'August 2012'
  19. __copyright__ = '(C) 2012, Victor Olaya'
  20. import os
  21. import re
  22. import sys
  23. from pathlib import Path
  24. from qgis.PyQt.QtCore import (
  25. QCoreApplication,
  26. QDir,
  27. QRectF,
  28. QPoint,
  29. QPointF,
  30. pyqtSignal,
  31. QUrl,
  32. QFileInfo)
  33. from qgis.PyQt.QtWidgets import (QMessageBox,
  34. QFileDialog)
  35. from qgis.core import (Qgis,
  36. QgsApplication,
  37. QgsProcessing,
  38. QgsProject,
  39. QgsProcessingModelParameter,
  40. QgsProcessingModelAlgorithm,
  41. QgsSettings,
  42. QgsProcessingContext,
  43. QgsFileUtils
  44. )
  45. from qgis.gui import (QgsProcessingParameterDefinitionDialog,
  46. QgsProcessingParameterWidgetContext,
  47. QgsModelGraphicsScene,
  48. QgsModelDesignerDialog,
  49. QgsProcessingContextGenerator,
  50. QgsProcessingParametersGenerator)
  51. from qgis.utils import iface
  52. from processing.gui.AlgorithmDialog import AlgorithmDialog
  53. from processing.modeler.ModelerParameterDefinitionDialog import ModelerParameterDefinitionDialog
  54. from processing.modeler.ModelerParametersDialog import ModelerParametersDialog
  55. from processing.modeler.ModelerScene import ModelerScene
  56. from processing.modeler.ModelerUtils import ModelerUtils
  57. from processing.modeler.ProjectProvider import PROJECT_PROVIDER_ID
  58. from processing.script.ScriptEditorDialog import ScriptEditorDialog
  59. from processing.tools.dataobjects import createContext
  60. pluginPath = os.path.split(os.path.dirname(__file__))[0]
  61. class ModelerDialog(QgsModelDesignerDialog):
  62. CANVAS_SIZE = 4000
  63. update_model = pyqtSignal()
  64. dlgs = []
  65. @staticmethod
  66. def create(model=None):
  67. """
  68. Workaround crappy sip handling of QMainWindow. It doesn't know that we are using the deleteonclose
  69. flag, so happily just deletes dialogs as soon as they go out of scope. The only workaround possible
  70. while we still have to drag around this Python code is to store a reference to the sip wrapper so that
  71. sip doesn't get confused. The underlying object will still be deleted by the deleteonclose flag though!
  72. """
  73. dlg = ModelerDialog(model)
  74. ModelerDialog.dlgs.append(dlg)
  75. return dlg
  76. def __init__(self, model=None, parent=None):
  77. super().__init__(parent)
  78. if iface is not None:
  79. self.toolbar().setIconSize(iface.iconSize())
  80. self.setStyleSheet(iface.mainWindow().styleSheet())
  81. scene = ModelerScene(self)
  82. scene.setSceneRect(QRectF(0, 0, self.CANVAS_SIZE, self.CANVAS_SIZE))
  83. self.setModelScene(scene)
  84. self.view().ensureVisible(0, 0, 10, 10)
  85. self.view().scale(QgsApplication.desktop().logicalDpiX() / 96, QgsApplication.desktop().logicalDpiX() / 96)
  86. self.actionOpen().triggered.connect(self.openModel)
  87. self.actionSaveInProject().triggered.connect(self.saveInProject)
  88. self.actionRun().triggered.connect(self.runModel)
  89. if model is not None:
  90. _model = model.create()
  91. _model.setSourceFilePath(model.sourceFilePath())
  92. self.setModel(_model)
  93. self.view().centerOn(0, 0)
  94. self.processing_context = createContext()
  95. class ContextGenerator(QgsProcessingContextGenerator):
  96. def __init__(self, context):
  97. super().__init__()
  98. self.processing_context = context
  99. def processingContext(self):
  100. return self.processing_context
  101. self.context_generator = ContextGenerator(self.processing_context)
  102. def runModel(self):
  103. valid, errors = self.model().validate()
  104. if not valid:
  105. message_box = QMessageBox()
  106. message_box.setWindowTitle(self.tr('Model is Invalid'))
  107. message_box.setIcon(QMessageBox.Warning)
  108. message_box.setText(self.tr('This model is not valid and contains one or more issues. Are you sure you want to run it in this state?'))
  109. message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel)
  110. message_box.setDefaultButton(QMessageBox.Cancel)
  111. error_string = ''
  112. for e in errors:
  113. e = re.sub(r'<[^>]*>', '', e)
  114. error_string += f'• {e}\n'
  115. message_box.setDetailedText(error_string)
  116. if message_box.exec_() == QMessageBox.Cancel:
  117. return
  118. def on_finished(successful, results):
  119. self.setLastRunChildAlgorithmResults(dlg.results().get('CHILD_RESULTS', {}))
  120. self.setLastRunChildAlgorithmInputs(dlg.results().get('CHILD_INPUTS', {}))
  121. dlg = AlgorithmDialog(self.model().create(), parent=self)
  122. dlg.setLogLevel(QgsProcessingContext.ModelDebug)
  123. dlg.setParameters(self.model().designerParameterValues())
  124. dlg.algorithmFinished.connect(on_finished)
  125. dlg.exec_()
  126. if dlg.wasExecuted():
  127. self.model().setDesignerParameterValues(dlg.createProcessingParameters(flags=QgsProcessingParametersGenerator.Flags(QgsProcessingParametersGenerator.Flag.SkipDefaultValueParameters)))
  128. def saveInProject(self):
  129. if not self.validateSave(QgsModelDesignerDialog.SaveAction.SaveInProject):
  130. return
  131. self.model().setSourceFilePath(None)
  132. project_provider = QgsApplication.processingRegistry().providerById(PROJECT_PROVIDER_ID)
  133. project_provider.add_model(self.model())
  134. self.update_model.emit()
  135. self.messageBar().pushMessage("", self.tr("Model was saved inside current project"), level=Qgis.Success,
  136. duration=5)
  137. self.setDirty(False)
  138. QgsProject.instance().setDirty(True)
  139. def saveModel(self, saveAs) -> bool:
  140. if not self.validateSave(QgsModelDesignerDialog.SaveAction.SaveAsFile):
  141. return False
  142. model_name_matched_file_name = self.model().modelNameMatchesFilePath()
  143. if self.model().sourceFilePath() and not saveAs:
  144. filename = self.model().sourceFilePath()
  145. else:
  146. if self.model().sourceFilePath():
  147. initial_path = Path(self.model().sourceFilePath())
  148. elif self.model().name():
  149. initial_path = Path(ModelerUtils.modelsFolders()[0]) / (self.model().name() + '.model3')
  150. else:
  151. initial_path = Path(ModelerUtils.modelsFolders()[0])
  152. filename, _ = QFileDialog.getSaveFileName(self,
  153. self.tr('Save Model'),
  154. initial_path.as_posix(),
  155. self.tr('Processing models (*.model3 *.MODEL3)'))
  156. if not filename:
  157. return False
  158. filename = QgsFileUtils.ensureFileNameHasExtension(filename, ['model3'])
  159. self.model().setSourceFilePath(filename)
  160. if not self.model().name() or self.model().name() == self.tr('model'):
  161. self.setModelName(Path(filename).stem)
  162. elif saveAs and model_name_matched_file_name:
  163. # if saving as, and the model name used to match the filename, then automatically update the
  164. # model name to match the new file name
  165. self.setModelName(Path(filename).stem)
  166. if not self.model().toFile(filename):
  167. if saveAs:
  168. QMessageBox.warning(self, self.tr('I/O error'),
  169. self.tr('Unable to save edits. Reason:\n {0}').format(str(sys.exc_info()[1])))
  170. else:
  171. QMessageBox.warning(self, self.tr("Can't save model"),
  172. self.tr(
  173. "This model can't be saved in its original location (probably you do not "
  174. "have permission to do it). Please, use the 'Save as…' option."))
  175. return False
  176. self.update_model.emit()
  177. if saveAs:
  178. self.messageBar().pushMessage("", self.tr("Model was saved to <a href=\"{}\">{}</a>").format(
  179. QUrl.fromLocalFile(filename).toString(), QDir.toNativeSeparators(filename)), level=Qgis.Success,
  180. duration=5)
  181. self.setDirty(False)
  182. return True
  183. def openModel(self):
  184. if not self.checkForUnsavedChanges():
  185. return
  186. settings = QgsSettings()
  187. last_dir = settings.value('Processing/lastModelsDir', QDir.homePath())
  188. filename, selected_filter = QFileDialog.getOpenFileName(self,
  189. self.tr('Open Model'),
  190. last_dir,
  191. self.tr('Processing models (*.model3 *.MODEL3)'))
  192. if filename:
  193. settings.setValue('Processing/lastModelsDir',
  194. QFileInfo(filename).absoluteDir().absolutePath())
  195. self.loadModel(filename)
  196. def repaintModel(self, showControls=True):
  197. scene = ModelerScene(self)
  198. scene.setSceneRect(QRectF(0, 0, self.CANVAS_SIZE,
  199. self.CANVAS_SIZE))
  200. if not showControls:
  201. scene.setFlag(QgsModelGraphicsScene.FlagHideControls)
  202. showComments = QgsSettings().value("/Processing/Modeler/ShowComments", True, bool)
  203. if not showComments:
  204. scene.setFlag(QgsModelGraphicsScene.FlagHideComments)
  205. context = createContext()
  206. self.setModelScene(scene)
  207. # create items later that setModelScene to setup link to messageBar to the scene
  208. scene.createItems(self.model(), context)
  209. def create_widget_context(self):
  210. """
  211. Returns a new widget context for use in the model editor
  212. """
  213. widget_context = QgsProcessingParameterWidgetContext()
  214. widget_context.setProject(QgsProject.instance())
  215. if iface is not None:
  216. widget_context.setMapCanvas(iface.mapCanvas())
  217. widget_context.setActiveLayer(iface.activeLayer())
  218. widget_context.setModel(self.model())
  219. return widget_context
  220. def autogenerate_parameter_name(self, parameter):
  221. """
  222. Automatically generates and sets a new parameter's name, based on the parameter's
  223. description and ensuring that it is unique for the model.
  224. """
  225. safeName = QgsProcessingModelAlgorithm.safeName(parameter.description())
  226. name = safeName.lower()
  227. i = 2
  228. while self.model().parameterDefinition(name):
  229. name = safeName.lower() + str(i)
  230. i += 1
  231. parameter.setName(name)
  232. def addInput(self, paramType, pos=None):
  233. if paramType not in [param.id() for param in QgsApplication.instance().processingRegistry().parameterTypes()]:
  234. return
  235. new_param = None
  236. comment = None
  237. if ModelerParameterDefinitionDialog.use_legacy_dialog(paramType=paramType):
  238. dlg = ModelerParameterDefinitionDialog(self.model(), paramType)
  239. if dlg.exec_():
  240. new_param = dlg.param
  241. comment = dlg.comments()
  242. else:
  243. # yay, use new API!
  244. context = createContext()
  245. widget_context = self.create_widget_context()
  246. dlg = QgsProcessingParameterDefinitionDialog(type=paramType,
  247. context=context,
  248. widgetContext=widget_context,
  249. algorithm=self.model())
  250. dlg.registerProcessingContextGenerator(self.context_generator)
  251. if dlg.exec_():
  252. new_param = dlg.createParameter()
  253. self.autogenerate_parameter_name(new_param)
  254. comment = dlg.comments()
  255. if new_param is not None:
  256. if pos is None or not pos:
  257. pos = self.getPositionForParameterItem()
  258. if isinstance(pos, QPoint):
  259. pos = QPointF(pos)
  260. component = QgsProcessingModelParameter(new_param.name())
  261. component.setDescription(new_param.name())
  262. component.setPosition(pos)
  263. component.comment().setDescription(comment)
  264. component.comment().setPosition(component.position() + QPointF(
  265. component.size().width(),
  266. -1.5 * component.size().height()))
  267. self.beginUndoCommand(self.tr('Add Model Input'))
  268. self.model().addModelParameter(new_param, component)
  269. self.repaintModel()
  270. # self.view().ensureVisible(self.scene.getLastParameterItem())
  271. self.endUndoCommand()
  272. def getPositionForParameterItem(self):
  273. MARGIN = 20
  274. BOX_WIDTH = 200
  275. BOX_HEIGHT = 80
  276. if len(self.model().parameterComponents()) > 0:
  277. maxX = max([i.position().x() for i in list(self.model().parameterComponents().values())])
  278. newX = min(MARGIN + BOX_WIDTH + maxX, self.CANVAS_SIZE - BOX_WIDTH)
  279. else:
  280. newX = MARGIN + BOX_WIDTH / 2
  281. return QPointF(newX, MARGIN + BOX_HEIGHT / 2)
  282. def addAlgorithm(self, alg_id, pos=None):
  283. alg = QgsApplication.processingRegistry().createAlgorithmById(alg_id)
  284. if not alg:
  285. return
  286. dlg = ModelerParametersDialog(alg, self.model())
  287. if dlg.exec_():
  288. alg = dlg.createAlgorithm()
  289. if pos is None or not pos:
  290. alg.setPosition(self.getPositionForAlgorithmItem())
  291. else:
  292. alg.setPosition(pos)
  293. alg.comment().setPosition(alg.position() + QPointF(
  294. alg.size().width(),
  295. -1.5 * alg.size().height()))
  296. output_offset_x = alg.size().width()
  297. output_offset_y = 1.5 * alg.size().height()
  298. for out in alg.modelOutputs():
  299. alg.modelOutput(out).setPosition(alg.position() + QPointF(output_offset_x, output_offset_y))
  300. output_offset_y += 1.5 * alg.modelOutput(out).size().height()
  301. self.beginUndoCommand(self.tr('Add Algorithm'))
  302. id = self.model().addChildAlgorithm(alg)
  303. self.repaintModel()
  304. self.endUndoCommand()
  305. res, errors = self.model().validateChildAlgorithm(id)
  306. if not res:
  307. self.view().scene().showWarning(
  308. QCoreApplication.translate('ModelerDialog', 'Algorithm “{}” is invalid').format(alg.description()),
  309. self.tr('Algorithm is Invalid'),
  310. QCoreApplication.translate('ModelerDialog', "<p>The “{}” algorithm is invalid, because:</p><ul><li>{}</li></ul>").format(alg.description(), '</li><li>'.join(errors)),
  311. level=Qgis.Warning
  312. )
  313. else:
  314. self.view().scene().messageBar().clearWidgets()
  315. def getPositionForAlgorithmItem(self):
  316. MARGIN = 20
  317. BOX_WIDTH = 200
  318. BOX_HEIGHT = 80
  319. if self.model().childAlgorithms():
  320. maxX = max([alg.position().x() for alg in list(self.model().childAlgorithms().values())])
  321. maxY = max([alg.position().y() for alg in list(self.model().childAlgorithms().values())])
  322. newX = min(MARGIN + BOX_WIDTH + maxX, self.CANVAS_SIZE - BOX_WIDTH)
  323. newY = min(MARGIN + BOX_HEIGHT + maxY, self.CANVAS_SIZE
  324. - BOX_HEIGHT)
  325. else:
  326. newX = MARGIN + BOX_WIDTH / 2
  327. newY = MARGIN * 2 + BOX_HEIGHT + BOX_HEIGHT / 2
  328. return QPointF(newX, newY)
  329. def exportAsScriptAlgorithm(self):
  330. dlg = ScriptEditorDialog(parent=iface.mainWindow())
  331. dlg.editor.setText('\n'.join(self.model().asPythonCode(QgsProcessing.PythonQgsProcessingAlgorithmSubclass, 4)))
  332. dlg.show()