assetwidget.py 18.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# vim: set fileencoding=utf-8 :
###############################################################################
#                                                                             #
# Copyright (c) 2019 Idiap Research Institute, http://www.idiap.ch/           #
# Contact: beat.support@idiap.ch                                              #
#                                                                             #
# This file is part of the beat.editor module of the BEAT platform.           #
#                                                                             #
# Commercial License Usage                                                    #
# Licensees holding valid commercial BEAT licenses may use this file in       #
# accordance with the terms contained in a written agreement between you      #
# and Idiap. For further information contact tto@idiap.ch                     #
#                                                                             #
# Alternatively, this file may be used under the terms of the GNU Affero      #
# Public License version 3 as published by the Free Software and appearing    #
# in the file LICENSE.AGPL included in the packaging of this file.            #
# The BEAT platform is distributed in the hope that it will be useful, but    #
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY  #
# or FITNESS FOR A PARTICULAR PURPOSE.                                        #
#                                                                             #
# You should have received a copy of the GNU Affero Public License along      #
# with the BEAT platform. If not, see http://www.gnu.org/licenses/.           #
#                                                                             #
###############################################################################

26
import os
Samuel GAIST's avatar
Samuel GAIST committed
27

28
import click
29

30
from PyQt5.QtCore import QFileSystemWatcher
31
from PyQt5.QtCore import QTimer
Samuel GAIST's avatar
Samuel GAIST committed
32
33
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtCore import pyqtSlot
34
from PyQt5.QtGui import QIcon
Samuel GAIST's avatar
Samuel GAIST committed
35
from PyQt5.QtWidgets import QHBoxLayout
36
from PyQt5.QtWidgets import QLabel
37
from PyQt5.QtWidgets import QMenu
Samuel GAIST's avatar
Samuel GAIST committed
38
from PyQt5.QtWidgets import QMessageBox
39
from PyQt5.QtWidgets import QPushButton
Samuel GAIST's avatar
Samuel GAIST committed
40
41
from PyQt5.QtWidgets import QStackedWidget
from PyQt5.QtWidgets import QStyle
42
43
44
45
from PyQt5.QtWidgets import QTabWidget
from PyQt5.QtWidgets import QTextEdit
from PyQt5.QtWidgets import QVBoxLayout
from PyQt5.QtWidgets import QWidget
46

47
from ..backend.asset import Asset
Samuel GAIST's avatar
Samuel GAIST committed
48
from ..backend.asset import AssetType
49
from ..backend.experimentmodel import ExperimentModel
50
from ..backend.resourcemodels import experiment_resources
51
from ..decorators import frozen
52
from .algorithmeditor import AlgorithmEditor
53
54
from .algorithmeditor import migrate_to_api_v2
from .algorithmeditor import update_code
55
56
from .databaseeditor import DatabaseEditor
from .dataformateditor import DataformatEditor
Samuel GAIST's avatar
Samuel GAIST committed
57
from .editor import PlaceholderEditor
58
59
60
61
from .experimenteditor import ExperimentEditor
from .libraryeditor import LibraryEditor
from .plottereditor import PlotterEditor
from .plotterparameterseditor import PlotterParametersEditor
62
from .protocoltemplateeditor import ProtocolTemplateEditor
63
64
65
66
67
68
69
70
71
72
73
from .toolchaineditor import ToolchainEditor


def widget_for_asset_type(asset_type):
    """Factory method to create the correct widget for the given asset_type.

    :param asset_type AssetType: type of asset

    :return: the editor matching the asset type
    """

74
    editor = None
75
    if asset_type == AssetType.UNKNOWN:
76
77
78
        editor = PlaceholderEditor()
    elif asset_type == AssetType.ALGORITHM:
        editor = AlgorithmEditor()
79
    elif asset_type == AssetType.DATABASE:
80
        editor = DatabaseEditor()
81
    elif asset_type == AssetType.DATAFORMAT:
82
        editor = DataformatEditor()
83
    elif asset_type == AssetType.EXPERIMENT:
84
        editor = ExperimentEditor()
85
    elif asset_type == AssetType.LIBRARY:
86
        editor = LibraryEditor()
87
    elif asset_type == AssetType.PLOTTER:
88
        editor = PlotterEditor()
89
    elif asset_type == AssetType.PLOTTERPARAMETER:
90
91
92
        editor = PlotterParametersEditor()
    elif asset_type == AssetType.PROTOCOLTEMPLATE:
        editor = ProtocolTemplateEditor()
93
    elif asset_type == AssetType.TOOLCHAIN:
94
        editor = ToolchainEditor()
95
96
    else:
        raise RuntimeError("Invalid asset type given {}".format(asset_type))
97
    return editor
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120


class FileBlocker:
    """Context manager to suspend QFileSystemWatcher from watching a file"""

    def __init__(self, watcher, path):
        """Constructor

        :param watch QFileSystemWatcher: watcher to suspend
        :param path str: path to suspend watching from
        """
        self.watcher = watcher
        self.path = path

    def __enter__(self):
        """Stop watching file"""

        self.watcher.removePath(self.path)

    def __exit__(self, *args):
        """Start watching file again"""

        self.watcher.addPath(self.path)
121
122


123
@frozen
124
125
126
127
128
129
130
131
class AssetWidget(QWidget):
    """
    This widget will show the asset specific editor and the JSON view of it.

    The corresponding file will be watched and the the widget refreshed
    accordingly.
    """

132
133
    currentAssetChanged = pyqtSignal([Asset])

134
135
136
    def __init__(self, parent=None):
        """Constructor"""

137
        super().__init__(parent)
138

139
        self.experiment_model = ExperimentModel()
140
        self.context = None
141
        self.current_asset = None
142
        self.watcher = QFileSystemWatcher()
143
144
145
        self.update_timer = QTimer()
        self.update_timer.setSingleShot(True)
        self.update_timer.setInterval(200)
146

147
148
        self.json_widget = QTextEdit()
        self.json_widget.setReadOnly(True)
149
150

        self.editors = QStackedWidget()
151
        self.type_editor_map = {}
152
153
154
155

        for asset_type in AssetType:
            editor = widget_for_asset_type(asset_type)
            self.editors.addWidget(editor)
156
            self.type_editor_map[asset_type] = editor
157

158
159
160
        self.tab_widget = QTabWidget()
        self.tab_widget.addTab(self.editors, self.tr("Editor"))
        self.tab_widget.addTab(self.json_widget, self.tr("Raw JSON"))
161

162
        self.asset_name_label = QLabel(self.tr("Unknown"))
163
        layout = QVBoxLayout(self)
164
        layout.addWidget(self.asset_name_label)
165
        layout.addWidget(self.tab_widget)
166

Samuel GAIST's avatar
Samuel GAIST committed
167
        edit_menu = QMenu(self)
168
169
170
171
172
173
        self.edit_code_action = edit_menu.addAction(self.tr("Code"))
        self.edit_documentation_action = edit_menu.addAction(self.tr("Documentation"))

        edit_button = QPushButton(self.tr("Edit"))
        edit_button.setMenu(edit_menu)

174
175
176
177
178
        self.save_button = QPushButton(self.tr("Save"))
        self.save_button.setEnabled(False)

        button_layout = QHBoxLayout()
        button_layout.addStretch(1)
179
        button_layout.addWidget(edit_button)
180
181
182
183
        button_layout.addWidget(self.save_button)

        layout.addLayout(button_layout)

184
185
186
        self.watcher.fileChanged.connect(self.__reloadFromHarddrive)
        self.update_timer.timeout.connect(self.__enableSave)
        self.update_timer.timeout.connect(self.__updateJsonWidget)
187
        self.save_button.clicked.connect(self.saveJson)
188
189
190
        self.edit_code_action.triggered.connect(self.__editCode)
        self.edit_documentation_action.triggered.connect(self.__editDocumentation)

191
192
193
        for action in self.create_actions():
            action.triggered.connect(self.__onCreateActionTriggered)

194
195
        experiment_editor = self.type_editor_map[AssetType.EXPERIMENT]
        experiment_editor.blockChanged.connect(self.__onBlockChanged)
196
        self.set_current_editor(AssetType.UNKNOWN)
197

198
199
200
201
202
203
    @property
    def current_editor(self):
        """Returns the current visible editor """

        return self.editors.currentWidget()

204
205
206
    def set_current_editor(self, asset_type):
        """Set the current editor"""

207
        self.editors.setCurrentWidget(self.type_editor_map[asset_type])
208
209
        self.edit_code_action.setEnabled(asset_type.has_code())
        self.edit_documentation_action.setEnabled(asset_type is not AssetType.UNKNOWN)
210

211
212
213
214
215
216
    @property
    def prefix_root_path(self):
        """Returns the prefix root path"""

        return self.context.meta["config"].path

217
    def __update_content(self, json_data):
218
        """Update the content of this widget"""
219

220
        try:
221
            self.current_editor.dataChanged.disconnect(self.update_timer)
222
223
224
        except TypeError:
            # Nothing was connected yet
            pass
225

226
227
        editor = self.type_editor_map[self.current_asset.type]
        editor.load_json(json_data)
228
229
230
231

        if self.current_asset.type == AssetType.EXPERIMENT:
            editor.loadToolchainData("/".join(self.current_asset.name.split("/")[1:4]))

232
233
234
        editor.dataChanged.connect(self.update_timer.start)
        self.set_current_editor(self.current_asset.type)
        self.__updateJsonWidget()
235

236
        self.asset_name_label.setText(self.current_asset.name)
237
        self.save_button.setEnabled(False)
238

239
240
241
242
243
244
245
246
247
    def __update_editors_icon(self, is_valid):
        tab_index = self.tab_widget.indexOf(self.editors)
        tab_icon = (
            self.style().standardIcon(QStyle.SP_MessageBoxCritical)
            if not is_valid
            else QIcon()
        )
        self.tab_widget.setTabIcon(tab_index, tab_icon)

248
249
250
251
252
253
254
    def __clear_watcher(self):
        """Clears the content of the file system watcher"""

        files = self.watcher.files()
        if files:
            self.watcher.removePaths(files)

255
    @pyqtSlot()
256
    def __enableSave(self):
257
        """Enable the save button"""
258

259
        self.save_button.setEnabled(True)
260

261
    @pyqtSlot()
262
    def __reloadFromHarddrive(self):
263
264
265
266
267
        """Reload the content of this widget from the hard drive"""

        answer = QMessageBox.question(
            self,
            self.tr("File changed"),
268
269
            self.tr(
                "The file:\n{}\nhas changed on disk.\n"
270
                "Do you want to reload it ?".format(self.current_asset.declaration_path)
271
            ),
272
273
274
        )

        if answer == QMessageBox.Yes:
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
            self.__update_content(self.current_asset.declaration)

    @pyqtSlot()
    def __onCreateActionTriggered(self):
        action = self.sender()

        editor = [
            editor
            for editor in self.type_editor_map.values()
            if editor.create_action == action
        ][0]

        self.maybe_save()

        asset, json_data = editor.createNewAsset()

        if asset and json_data:
            self.__clear_watcher()
293
            self.current_asset = asset
294
295
296
            self.__update_content(json_data)
            self.current_editor.setDirty()
            self.save_button.setEnabled(True)
297
298
            self.__update_editors_icon(False)
            self.watcher.addPath(asset.declaration_path)
299
300
            if asset.type == AssetType.EXPERIMENT:
                self.experiment_model.load_experiment(asset)
301
            self.currentAssetChanged.emit(asset)
302
303
        elif asset:
            self.loadAsset(asset)
304

305
306
307
308
309
310
311
    @pyqtSlot(str, dict)
    def __onBlockChanged(self, block_name, configuration):
        self.experiment_model.update_block(block_name, configuration)
        errors_map = self.experiment_model.check_all_blocks()
        experiment_editor = self.type_editor_map[AssetType.EXPERIMENT]
        experiment_editor.setBlockErrors(errors_map)

312
    @pyqtSlot()
313
    def __updateJsonWidget(self):
314
        self.json_widget.setText(self.current_editor.dump_as_string())
315

316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
    @pyqtSlot()
    def __editDocumentation(self):
        path = self.current_asset.documentation_path
        if not os.path.exists(path):
            answer = QMessageBox.question(
                self,
                self.tr("File not found"),
                self.tr(
                    "The documentation file does not exist.\n Would you like to create one ?"
                ),
            )
            if answer == QMessageBox.No:
                return
            with open(path, "wt") as doc_file:
                doc_file.write(self.tr("Empty"))
        click.launch(url=path)

    @pyqtSlot()
    def __editCode(self):
        if not self.current_asset.type.has_code():
            return
        path = self.current_asset.code_path
        click.launch(url=path)

340
341
    def closeEvent(self, event):
        """Re-impl will check and ask to save if the editor is dirty"""
342

343
        self.maybe_save()
344
        super().closeEvent(event)
345

346
347
    def maybe_save(self):
        """If the editor has been modified ask for saving"""
348

349
        if self.current_editor.isDirty():
350
351
            answer = QMessageBox.question(
                self,
352
353
                self.tr("Content changed"),
                self.tr(f"Do you wish to save:\n\n{self.current_asset.name}\n\n?"),
354
355
356
            )

            if answer == QMessageBox.Yes:
357
                self.saveJson()
358
            else:
359
                self.current_editor.clearDirty()
360
361

        self.save_button.setEnabled(False)
362

363
364
365
366
    def set_context(self, context):
        """Sets the BEAT context"""

        self.context = context
367

368
369
        experiment_resources.setContext(self.context)

370
        for i in range(0, self.editors.count()):
371
            self.editors.widget(i).set_context(context)
372

373
374
375
    def create_actions(self):
        """Return the creation actions of all editors"""

376
        action_list = [editor.create_action for editor in self.type_editor_map.values()]
377
378
        return filter(None, action_list)

379
    @pyqtSlot("QString")
380
    def deleteAsset(self, file_path):
381
382
383
384
385
        """Delete the requested asset

        :param file_path str: path to the json file of the asset to delete
        """

386
        if self.current_asset and self.current_asset.declaration_path == file_path:
387
            # Check before deletion
388
389
390
391
            answer = QMessageBox.question(
                self,
                self.tr("Deletion requested"),
                self.tr(
392
                    f"You are about to delete the asset you are currently editing:\n\n{self.current_asset.name}\n\nAre you sure you want to do that ?"
393
394
395
396
397
                ),
            )
            if answer == QMessageBox.No:
                return
            self.set_current_editor(AssetType.UNKNOWN)
398
            self.asset_name_label.setText(self.tr("Unknown"))
399
            self.json_widget.clear()
400
            self.__clear_watcher()
401
402
403
            self.current_asset.delete()
            self.current_asset = None
        else:
404
            # Check before deletion
405
            asset = Asset.from_path(self.prefix_root_path, file_path)
406
            asset_type = asset.type.name.lower()
407
408
409
410
            answer = QMessageBox.question(
                self,
                self.tr("Deletion requested"),
                self.tr(
411
                    f"You are about to delete an asset of type {asset_type} named:\n\n{asset.name}\n\nAre you sure you want to do that ?"
412
413
414
415
                ),
            )
            if answer == QMessageBox.No:
                return
416
417
            asset.delete()

418
419
420
421
422
423
    def confirm_loading(self, errors):
        """Request loading confirmation"""

        message_box = QMessageBox(
            QMessageBox.Critical,
            self.tr("Invalid asset"),
424
            self.tr("The asset you are trying to load is invalid."),
425
426
427
428
429
430
431
432
        )
        message_box.setDetailedText(f"{errors}")
        message_box.addButton(QMessageBox.Cancel)
        load_button = message_box.addButton(QMessageBox.Ignore)
        load_button.setText(self.tr("Load anyway"))

        return message_box.exec_()

433
434
    @pyqtSlot(Asset)
    def loadAsset(self, asset):
435
        """ Load the content of the file given in parameter
436

437
        :param asset Asset: asset to edit
438
439
        """

440
        if self.current_asset == asset and not self.current_editor.isDirty():
441
442
443
444
            return

        self.maybe_save()

445
        self.__clear_watcher()
446

447
        is_valid, errors = asset.is_valid()
448
449
        do_load = False

450
        if not is_valid:
451
            result = self.confirm_loading(errors)
452
            do_load = result == QMessageBox.Ignore
453
        else:
454
455
456
457
458
            do_load = True

        if do_load:
            self.__update_editors_icon(is_valid)

459
            declaration = asset.declaration
460
461
462
463
464
465
466
467
468
469
470
471
472
473

            # Check if asset description size is bigger to what is allowed by the current editor
            if (
                len(declaration.get("description", ""))
                > self.current_editor.description_max_length
            ):
                QMessageBox.warning(
                    self,
                    self.tr("Description size"),
                    self.tr(
                        "The loaded description is too big and will be truncated. Please update your description."
                    ),
                )

474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
            if asset.type == AssetType.ALGORITHM:
                if declaration.get("api_version", 0) < 2:
                    answer = QMessageBox.question(
                        self,
                        self.tr("Outdated content"),
                        self.tr(
                            "This algorithm implements an obsolete API\nWould you like to create a new updated version of it ?"
                        ),
                    )

                    if answer == QMessageBox.No:
                        return

                    status, asset = migrate_to_api_v2(asset)
                    if status:
                        update_code(asset)
                    else:
                        QMessageBox.information(
                            self,
                            self.tr("Error occured"),
                            self.tr("Failed to create new version"),
                        )
                        return
497
498
            elif asset.type == AssetType.EXPERIMENT:
                self.experiment_model.load_experiment(asset)
499

500
501
            self.watcher.addPath(asset.declaration_path)
            self.current_asset = asset
502
            self.__update_content(declaration)
503
            self.currentAssetChanged.emit(asset)
504

505
506
    @pyqtSlot()
    def saveJson(self):
507
508
509
510
        """Save the editor content back to the file"""

        json_data = self.current_editor.dump_json()

511
        declaration_path = self.current_asset.declaration_path
512
        with FileBlocker(self.watcher, declaration_path):
513
            self.current_asset.declaration = json_data
514

515
516
517
        is_valid, _ = self.current_asset.is_valid()
        self.__update_editors_icon(is_valid)

518
519
520
        if self.current_asset.type == AssetType.ALGORITHM:
            update_code(self.current_asset)

521
        self.current_editor.clearDirty()
522
        self.save_button.setEnabled(False)