Ejemplo n.º 1
0
class WorkspacesWidget(QWidget, Ui_WorkspacesWidget):
    RESET_TIMER_THRESHOLD = 1000  # ms

    fs_updated_qt = pyqtSignal(CoreEvent, UUID)
    fs_synced_qt = pyqtSignal(CoreEvent, UUID)
    entry_downsynced_qt = pyqtSignal(UUID, UUID)

    sharing_updated_qt = pyqtSignal(WorkspaceEntry, object)
    _workspace_created_qt = pyqtSignal(WorkspaceEntry)
    load_workspace_clicked = pyqtSignal(WorkspaceFS, FsPath, bool)
    workspace_reencryption_success = pyqtSignal(QtToTrioJob)
    workspace_reencryption_error = pyqtSignal(QtToTrioJob)
    workspace_reencryption_progress = pyqtSignal(EntryID, int, int)
    mountpoint_started = pyqtSignal(object, object)
    mountpoint_stopped = pyqtSignal(object, object)

    rename_success = pyqtSignal(QtToTrioJob)
    rename_error = pyqtSignal(QtToTrioJob)
    create_success = pyqtSignal(QtToTrioJob)
    create_error = pyqtSignal(QtToTrioJob)
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)
    mount_success = pyqtSignal(QtToTrioJob)
    mount_error = pyqtSignal(QtToTrioJob)
    unmount_success = pyqtSignal(QtToTrioJob)
    unmount_error = pyqtSignal(QtToTrioJob)
    reencryption_needs_success = pyqtSignal(QtToTrioJob)
    reencryption_needs_error = pyqtSignal(QtToTrioJob)
    ignore_success = pyqtSignal(QtToTrioJob)
    ignore_error = pyqtSignal(QtToTrioJob)

    def __init__(self, core, jobs_ctx, event_bus, **kwargs):
        super().__init__(**kwargs)
        self.setupUi(self)

        self.core = core
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.reencrypting = set()
        self.disabled_workspaces = self.core.config.disabled_workspaces

        self.layout_workspaces = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_workspaces)

        self.button_add_workspace.clicked.connect(
            self.create_workspace_clicked)
        self.button_goto_file.clicked.connect(self.goto_file_clicked)

        self.button_add_workspace.apply_style()
        self.button_goto_file.apply_style()

        self.fs_updated_qt.connect(self._on_fs_updated_qt)
        self.fs_synced_qt.connect(self._on_fs_synced_qt)
        self.entry_downsynced_qt.connect(self._on_entry_downsynced_qt)

        self.line_edit_search.textChanged.connect(self.on_workspace_filter)

        self.rename_success.connect(self.on_rename_success)
        self.rename_error.connect(self.on_rename_error)
        self.create_success.connect(self.on_create_success)
        self.create_error.connect(self.on_create_error)
        self.list_success.connect(self.on_list_success)
        self.list_error.connect(self.on_list_error)
        self.reencryption_needs_success.connect(
            self.on_reencryption_needs_success)
        self.reencryption_needs_error.connect(self.on_reencryption_needs_error)
        self.workspace_reencryption_progress.connect(
            self._on_workspace_reencryption_progress)
        self.mount_success.connect(self.on_mount_success)
        self.mount_error.connect(self.on_mount_error)
        self.unmount_success.connect(self.on_unmount_success)
        self.unmount_error.connect(self.on_unmount_error)

        self.workspace_reencryption_success.connect(
            self._on_workspace_reencryption_success)
        self.workspace_reencryption_error.connect(
            self._on_workspace_reencryption_error)

        self.filter_remove_button.clicked.connect(self.remove_user_filter)
        self.filter_remove_button.apply_style()

        self.reset_required = False
        self.reset_timer = QTimer()
        self.reset_timer.setInterval(self.RESET_TIMER_THRESHOLD)
        self.reset_timer.setSingleShot(True)
        self.reset_timer.timeout.connect(self.on_timeout)

        self.mountpoint_started.connect(self._on_mountpoint_started_qt)
        self.mountpoint_stopped.connect(self._on_mountpoint_stopped_qt)

        self.sharing_updated_qt.connect(self._on_sharing_updated_qt)
        self._workspace_created_qt.connect(self._on_workspace_created_qt)

        self.filter_user_info = None
        self.filter_layout_widget.hide()

    def remove_user_filter(self):
        self.filter_user_info = None
        self.filter_layout_widget.hide()
        self.reset()

    def set_user_info(self, user_info):
        self.filter_user_info = user_info
        self.filter_layout_widget.show()
        self.filter_label.setText(
            _("TEXT_WORKSPACE_FILTERED_user").format(
                user=user_info.short_user_display))

    def _iter_workspace_buttons(self):
        # TODO: this is needed because we insert the "no workspaces" QLabel in
        # layout_workspaces, of course it would be better to separate both...
        for item in self.layout_workspaces.items:
            widget = item.widget()
            if isinstance(widget, WorkspaceButton):
                yield widget

    def disconnect_all(self):
        pass

    def showEvent(self, event):
        self.event_bus.connect(CoreEvent.FS_WORKSPACE_CREATED,
                               self._on_workspace_created_trio)
        self.event_bus.connect(CoreEvent.FS_ENTRY_UPDATED,
                               self._on_fs_entry_updated_trio)
        self.event_bus.connect(CoreEvent.FS_ENTRY_SYNCED,
                               self._on_fs_entry_synced_trio)
        self.event_bus.connect(CoreEvent.SHARING_UPDATED,
                               self._on_sharing_updated_trio)
        self.event_bus.connect(CoreEvent.FS_ENTRY_DOWNSYNCED,
                               self._on_entry_downsynced_trio)
        self.event_bus.connect(CoreEvent.MOUNTPOINT_STARTED,
                               self._on_mountpoint_started_trio)
        self.event_bus.connect(CoreEvent.MOUNTPOINT_STOPPED,
                               self._on_mountpoint_stopped_trio)
        self.reset()

    def hideEvent(self, event):
        try:
            self.event_bus.disconnect(CoreEvent.FS_WORKSPACE_CREATED,
                                      self._on_workspace_created_trio)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_UPDATED,
                                      self._on_fs_entry_updated_trio)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_SYNCED,
                                      self._on_fs_entry_synced_trio)
            self.event_bus.disconnect(CoreEvent.SHARING_UPDATED,
                                      self._on_sharing_updated_trio)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_DOWNSYNCED,
                                      self._on_entry_downsynced_trio)
            self.event_bus.disconnect(CoreEvent.MOUNTPOINT_STARTED,
                                      self._on_mountpoint_started_trio)
            self.event_bus.disconnect(CoreEvent.MOUNTPOINT_STOPPED,
                                      self._on_mountpoint_stopped_trio)
        except ValueError:
            pass

    def has_workspaces_displayed(self):
        return self.layout_workspaces.count() >= 1 and isinstance(
            self.layout_workspaces.itemAt(0).widget(), WorkspaceButton)

    def goto_file_clicked(self):
        file_link = get_text_input(
            self,
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_TITLE"),
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_GOTO_FILE_LINK_PLACEHOLDER"),
            default_text="",
            button_text=_("ACTION_GOTO_FILE_LINK"),
        )
        if not file_link:
            return

        url = None
        try:
            url = BackendOrganizationFileLinkAddr.from_url(file_link)
        except ValueError as exc:
            show_error(self,
                       _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"),
                       exception=exc)
            return

        for widget in self._iter_workspace_buttons():
            if widget.workspace_fs.workspace_id == url.workspace_id:
                self.load_workspace(widget.workspace_fs,
                                    path=url.path,
                                    selected=True)
                return
        show_error(self,
                   _("TEXT_WORKSPACE_GOTO_FILE_LINK_WORKSPACE_NOT_FOUND"))

    def on_workspace_filter(self, pattern):
        pattern = pattern.lower()
        for widget in self._iter_workspace_buttons():
            if pattern and pattern not in widget.name.lower():
                widget.hide()
            else:
                widget.show()

    def load_workspace(self, workspace_fs, path=FsPath("/"), selected=False):
        self.load_workspace_clicked.emit(workspace_fs, path, selected)

    def on_create_success(self, job):
        self.remove_user_filter()

    def on_create_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_rename_success(self, job):
        workspace_button, workspace_name = job.ret
        if workspace_button:
            workspace_button.reload_workspace_name(workspace_name)

    def on_rename_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_list_success(self, job):
        self.spinner.hide()
        self.layout_workspaces.clear()
        workspaces = job.ret

        if not workspaces:
            self.line_edit_search.hide()
            label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
            label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
            self.layout_workspaces.addWidget(label)
            return

        self.line_edit_search.show()
        for workspace in workspaces:
            workspace_fs, ws_entry, users_roles, files, timestamped, reencryption_needs = workspace

            try:
                self.add_workspace(
                    workspace_fs,
                    ws_entry,
                    users_roles,
                    files,
                    timestamped=timestamped,
                    reencryption_needs=reencryption_needs,
                )
            except JobSchedulerNotAvailable:
                pass

    def on_list_error(self, job):
        self.spinner.hide()
        self.layout_workspaces.clear()
        label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
        label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.layout_workspaces.addWidget(label)

    def on_mount_success(self, job):
        self.reset()

    def on_mount_error(self, job):
        if isinstance(job.exc, MountpointError):
            workspace_id = job.arguments.get("workspace_id")
            timestamp = job.arguments.get("timestamp")
            wb = self.get_workspace_button(workspace_id, timestamp)
            if wb:
                wb.set_mountpoint_state(False)
            show_error(self,
                       _("TEXT_WORKSPACE_CANNOT_MOUNT"),
                       exception=job.exc)

    def on_unmount_success(self, job):
        self.reset()

    def on_unmount_error(self, job):
        if isinstance(job.exc, MountpointError):
            show_error(self,
                       _("TEXT_WORKSPACE_CANNOT_UNMOUNT"),
                       exception=job.exc)

    def on_reencryption_needs_success(self, job):
        workspace_id, reencryption_needs = job.ret
        for widget in self._iter_workspace_buttons():
            if widget.workspace_fs.workspace_id == workspace_id:
                widget.reencryption_needs = reencryption_needs
                break

    def on_reencryption_needs_error(self, job):
        pass

    def add_workspace(self, workspace_fs, ws_entry, users_roles, files,
                      timestamped, reencryption_needs):

        # The Qt thread should never hit the core directly.
        # Synchronous calls can run directly in the job system
        # as they won't block the Qt loop for long
        workspace_name = self.jobs_ctx.run_sync(
            workspace_fs.get_workspace_name)

        # Temporary code to fix the workspace names edited by
        # the previous naming policy (the userfs used to add
        # `(shared by <device>)` at the end of the workspace name)
        token = " (shared by "
        if token in workspace_name:
            workspace_name, *_ = workspace_name.split(token)
            self.jobs_ctx.submit_job(
                ThreadSafeQtSignal(self, "ignore_success", QtToTrioJob),
                ThreadSafeQtSignal(self, "ignore_error", QtToTrioJob),
                _do_workspace_rename,
                core=self.core,
                workspace_id=workspace_fs.workspace_id,
                new_name=workspace_name,
                button=None,
            )

        if self.filter_user_info is not None and self.filter_user_info.user_id not in users_roles:
            return

        button = WorkspaceButton(
            workspace_name=workspace_name,
            workspace_fs=workspace_fs,
            users_roles=users_roles,
            is_mounted=self.is_workspace_mounted(workspace_fs.workspace_id,
                                                 None),
            files=files[:4],
            timestamped=timestamped,
            reencryption_needs=reencryption_needs,
        )
        self.layout_workspaces.addWidget(button)
        button.clicked.connect(self.load_workspace)
        button.share_clicked.connect(self.share_workspace)
        button.reencrypt_clicked.connect(self.reencrypt_workspace)
        button.delete_clicked.connect(self.delete_workspace)
        button.rename_clicked.connect(self.rename_workspace)
        button.remount_ts_clicked.connect(self.remount_workspace_ts)
        button.open_clicked.connect(self.open_workspace)
        button.switch_clicked.connect(self._on_switch_clicked)

        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "reencryption_needs_success",
                               QtToTrioJob),
            ThreadSafeQtSignal(self, "reencryption_needs_error", QtToTrioJob),
            _get_reencryption_needs,
            workspace_fs=workspace_fs,
        )

    def _on_switch_clicked(self, state, workspace_fs, timestamp):
        if state:
            self.mount_workspace(workspace_fs.workspace_id, timestamp)
        else:
            self.unmount_workspace(workspace_fs.workspace_id, timestamp)
        if not timestamp:
            self.update_workspace_config(workspace_fs.workspace_id, state)

    def open_workspace(self, workspace_fs):
        self.open_workspace_file(workspace_fs, None)

    def open_workspace_file(self, workspace_fs, file_name):
        file_name = FsPath("/", file_name) if file_name else FsPath("/")

        try:
            # The Qt thread should never hit the core directly.
            # Synchronous calls can run directly in the job system
            # as they won't block the Qt loop for long
            path = self.jobs_ctx.run_sync(
                self.core.mountpoint_manager.get_path_in_mountpoint,
                workspace_fs.workspace_id,
                file_name,
                workspace_fs.timestamp
                if isinstance(workspace_fs, WorkspaceFSTimestamped) else None,
            )
        except MountpointNotMounted:
            # The mountpoint has been umounted in our back, nothing left to do
            pass

        desktop.open_file(str(path))

    def remount_workspace_ts(self, workspace_fs):
        def _on_finished(date, time):
            if not date or not time:
                return

            datetime = pendulum.datetime(
                date.year(),
                date.month(),
                date.day(),
                time.hour(),
                time.minute(),
                time.second(),
                tzinfo="local",
            )
            self.mount_workspace(workspace_fs.workspace_id, datetime)

        TimestampedWorkspaceWidget.show_modal(workspace_fs=workspace_fs,
                                              jobs_ctx=self.jobs_ctx,
                                              parent=self,
                                              on_finished=_on_finished)

    def mount_workspace(self, workspace_id, timestamp=None):
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "mount_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "mount_error", QtToTrioJob),
            _do_workspace_mount,
            core=self.core,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )

    def unmount_workspace(self, workspace_id, timestamp=None):
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "unmount_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "unmount_error", QtToTrioJob),
            _do_workspace_unmount,
            core=self.core,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )

    def update_workspace_config(self, workspace_id, state):
        if state:
            self.disabled_workspaces -= {workspace_id}
        else:
            self.disabled_workspaces |= {workspace_id}
        self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED,
                            disabled_workspaces=self.disabled_workspaces)

    def is_workspace_mounted(self, workspace_id, timestamp=None):
        return self.jobs_ctx.run_sync(
            self.core.mountpoint_manager.is_workspace_mounted, workspace_id,
            timestamp)

    def delete_workspace(self, workspace_fs):
        if isinstance(workspace_fs, WorkspaceFSTimestamped):
            self.unmount_workspace(workspace_fs.workspace_id,
                                   workspace_fs.timestamp)
            return
        else:
            workspace_name = self.jobs_ctx.run_sync(
                workspace_fs.get_workspace_name)
            result = ask_question(
                self,
                _("TEXT_WORKSPACE_DELETE_TITLE"),
                _("TEXT_WORKSPACE_DELETE_INSTRUCTIONS_workspace").format(
                    workspace=workspace_name),
                [_("ACTION_DELETE_WORKSPACE_CONFIRM"),
                 _("ACTION_CANCEL")],
            )
            if result != _("ACTION_DELETE_WORKSPACE_CONFIRM"):
                return
            # Workspace deletion is not available yet (button should be hidden anyway)

    def rename_workspace(self, workspace_button):
        new_name = get_text_input(
            self,
            _("TEXT_WORKSPACE_RENAME_TITLE"),
            _("TEXT_WORKSPACE_RENAME_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_RENAME_PLACEHOLDER"),
            default_text=workspace_button.name,
            button_text=_("ACTION_WORKSPACE_RENAME_CONFIRM"),
            validator=validators.WorkspaceNameValidator(),
        )
        if not new_name:
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "rename_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "rename_error", QtToTrioJob),
            _do_workspace_rename,
            core=self.core,
            workspace_id=workspace_button.workspace_fs.workspace_id,
            new_name=new_name,
            button=workspace_button,
        )

    def on_sharing_closing(self, has_changes):
        if has_changes:
            self.reset()

    def share_workspace(self, workspace_fs):
        WorkspaceSharingWidget.show_modal(
            user_fs=self.core.user_fs,
            workspace_fs=workspace_fs,
            core=self.core,
            jobs_ctx=self.jobs_ctx,
            parent=self,
            on_finished=self.on_sharing_closing,
        )

    def reencrypt_workspace(self, workspace_id, user_revoked, role_revoked,
                            reencryption_already_in_progress):
        if workspace_id in self.reencrypting or (
                not user_revoked and not role_revoked
                and not reencryption_already_in_progress):
            return

        question = ""
        if user_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REVOKED"))
        if role_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REMOVED"))
        question += _("TEXT_WORKSPACE_NEED_REENCRYPTION_INSTRUCTIONS")

        r = ask_question(
            self,
            _("TEXT_WORKSPACE_NEED_REENCRYPTION_TITLE"),
            question,
            [_("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"),
             _("ACTION_CANCEL")],
        )
        if r != _("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"):
            return

        @contextmanager
        def _handle_fs_errors():
            try:
                yield
            except FSBackendOfflineError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="offline-backend",
                                     origin=exc)
            except FSWorkspaceNoAccess as exc:
                raise JobResultError(ret=workspace_id,
                                     status="access-error",
                                     origin=exc)
            except FSWorkspaceNotFoundError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="not-found",
                                     origin=exc)
            except FSError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="fs-error",
                                     origin=exc)

        async def _reencrypt(on_progress, workspace_id):
            with _handle_fs_errors():
                if reencryption_already_in_progress:
                    job = await self.core.user_fs.workspace_continue_reencryption(
                        workspace_id)
                else:
                    job = await self.core.user_fs.workspace_start_reencryption(
                        workspace_id)
            while True:
                with _handle_fs_errors():
                    total, done = await job.do_one_batch()
                on_progress.emit(workspace_id, total, done)
                if total == done:
                    break
            return workspace_id

        self.reencrypting.add(workspace_id)

        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "workspace_reencryption_success",
                               QtToTrioJob),
            ThreadSafeQtSignal(self, "workspace_reencryption_error",
                               QtToTrioJob),
            _reencrypt,
            on_progress=ThreadSafeQtSignal(self,
                                           "workspace_reencryption_progress",
                                           EntryID, int, int),
            workspace_id=workspace_id,
        )

    def _on_workspace_reencryption_success(self, job):
        workspace_id = job.ret
        self.reencrypting.remove(workspace_id)

    def _on_workspace_reencryption_error(self, job):
        if job.status == "offline-backend":
            err_msg = _("TEXT_WORKPACE_REENCRYPT_OFFLINE_ERROR")
        elif job.status == "access-error":
            err_msg = _("TEXT_WORKPACE_REENCRYPT_ACCESS_ERROR")
        elif job.status == "not-found":
            err_msg = _("TEXT_WORKPACE_REENCRYPT_NOT_FOUND_ERROR")
        elif job.status == "fs-error":
            err_msg = _("TEXT_WORKPACE_REENCRYPT_FS_ERROR")
        else:
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_UNKOWN_ERROR")
        show_error(self, err_msg, exception=job.exc)

    def get_workspace_button(self, workspace_id, timestamp):
        for widget in self._iter_workspace_buttons():
            if widget.workspace_id == workspace_id and timestamp == widget.timestamp:
                return widget
        return None

    def _on_workspace_reencryption_progress(self, workspace_id, total, done):
        wb = self.get_workspace_button(workspace_id, None)
        if done == total:
            wb.reencrypting = None
        else:
            wb.reencrypting = (total, done)

    def create_workspace_clicked(self):
        workspace_name = get_text_input(
            parent=self,
            title=_("TEXT_WORKSPACE_NEW_TITLE"),
            message=_("TEXT_WORKSPACE_NEW_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_NEW_PLACEHOLDER"),
            button_text=_("ACTION_WORKSPACE_NEW_CREATE"),
            validator=validators.WorkspaceNameValidator(),
        )
        if not workspace_name:
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "create_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "create_error", QtToTrioJob),
            _do_workspace_create,
            core=self.core,
            workspace_name=workspace_name,
        )

    def reset(self):
        if self.reset_timer.isActive():
            self.reset_required = True
        else:
            self.reset_required = False
            self.reset_timer.start()
            self.list_workspaces()

    def on_timeout(self):
        if self.reset_required:
            self.reset()

    def list_workspaces(self):
        if not self.has_workspaces_displayed():
            self.spinner.show()
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "list_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "list_error", QtToTrioJob),
            _do_workspace_list,
            core=self.core,
        )

    def _on_sharing_updated_trio(self, event, new_entry, previous_entry):
        self.sharing_updated_qt.emit(new_entry, previous_entry)

    def _on_sharing_updated_qt(self, new_entry, previous_entry):
        self.reset()

    def _on_workspace_created_trio(self, event, new_entry):
        self._workspace_created_qt.emit(new_entry)

    def _on_workspace_created_qt(self, workspace_entry):
        self.reset()

    def _on_fs_entry_synced_trio(self, event, id, workspace_id=None):
        self.fs_synced_qt.emit(event, id)

    def _on_fs_entry_updated_trio(self, event, workspace_id=None, id=None):
        if workspace_id and not id:
            self.fs_updated_qt.emit(event, workspace_id)

    def _on_entry_downsynced_trio(self, event, workspace_id=None, id=None):
        self.entry_downsynced_qt.emit(workspace_id, id)

    def _on_entry_downsynced_qt(self, workspace_id, id):
        self.reset()

    def _on_fs_synced_qt(self, event, id):
        self.reset()

    def _on_fs_updated_qt(self, event, workspace_id):
        self.reset()

    def _on_mountpoint_started_qt(self, workspace_id, timestamp):
        wb = self.get_workspace_button(workspace_id, timestamp)
        if wb:
            wb.set_mountpoint_state(True)

    def _on_mountpoint_stopped_qt(self, workspace_id, timestamp):
        wb = self.get_workspace_button(workspace_id, timestamp)
        if wb:
            wb.set_mountpoint_state(False)

    def _on_mountpoint_started_trio(self, event, mountpoint, workspace_id,
                                    timestamp):
        self.mountpoint_started.emit(workspace_id, timestamp)

    def _on_mountpoint_stopped_trio(self, event, mountpoint, workspace_id,
                                    timestamp):
        self.mountpoint_stopped.emit(workspace_id, timestamp)
Ejemplo n.º 2
0
class UsersWidget(QWidget, Ui_UsersWidget):
    revoke_success = pyqtSignal(QtToTrioJob)
    revoke_error = pyqtSignal(QtToTrioJob)
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)
    invite_user_success = pyqtSignal(QtToTrioJob)
    invite_user_error = pyqtSignal(QtToTrioJob)
    cancel_invitation_success = pyqtSignal(QtToTrioJob)
    cancel_invitation_error = pyqtSignal(QtToTrioJob)
    filter_shared_workspaces_request = pyqtSignal(UserInfo)

    def __init__(self, core, jobs_ctx, event_bus, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setupUi(self)
        self.core = core
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.layout_users = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_users)
        self.button_add_user.apply_style()
        if core.device.is_admin:
            self.button_add_user.clicked.connect(self.invite_user)
        else:
            self.button_add_user.hide()
        self.button_previous_page.clicked.connect(self.show_previous_page)
        self.button_next_page.clicked.connect(self.show_next_page)
        self.button_users_filter.clicked.connect(self.on_filter)
        self.line_edit_search.textChanged.connect(
            lambda: self.on_filter(text_changed=True))
        self.line_edit_search.editingFinished.connect(
            lambda: self.on_filter(editing_finished=True))
        self.revoke_success.connect(self._on_revoke_success)
        self.revoke_error.connect(self._on_revoke_error)
        self.list_success.connect(self._on_list_success)
        self.list_error.connect(self._on_list_error)
        self.invite_user_success.connect(self._on_invite_user_success)
        self.invite_user_error.connect(self._on_invite_user_error)
        self.cancel_invitation_success.connect(
            self._on_cancel_invitation_success)
        self.cancel_invitation_error.connect(self._on_cancel_invitation_error)

    def show(self):
        self._page = 1
        self.reset()
        super().show()

    def show_next_page(self):
        self._page += 1
        self.on_filter(change_page=True)

    def show_previous_page(self):
        if self._page > 1:
            self._page -= 1
        self.on_filter(change_page=True)

    def on_filter(self,
                  editing_finished=False,
                  text_changed=False,
                  change_page=False):
        if change_page is False:
            self._page = 1
        pattern = self.line_edit_search.text()
        if text_changed and len(pattern) <= 0:
            return self.reset()
        elif text_changed:
            return
        self.spinner.show()
        self.button_users_filter.setEnabled(False)
        self.line_edit_search.setEnabled(False)
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "list_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "list_error", QtToTrioJob),
            _do_list_users_and_invitations,
            core=self.core,
            page=self._page,
            pattern=pattern,
        )

    def invite_user(self):
        user_email = get_text_input(
            self,
            _("TEXT_USER_INVITE_EMAIL"),
            _("TEXT_USER_INVITE_EMAIL_INSTRUCTIONS"),
            placeholder=_("TEXT_USER_INVITE_EMAIL_PLACEHOLDER"),
            button_text=_("ACTION_USER_INVITE_DO_INVITE"),
            validator=validators.EmailValidator(),
        )
        if not user_email:
            return

        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "invite_user_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "invite_user_error", QtToTrioJob),
            _do_invite_user,
            core=self.core,
            email=user_email,
        )

    def add_user(self, user_info, is_current_user):
        button = UserButton(
            user_info=user_info,
            is_current_user=is_current_user,
            current_user_is_admin=self.core.device.is_admin,
        )
        self.layout_users.addWidget(button)
        button.filter_user_workspaces_clicked.connect(
            self.filter_shared_workspaces_request.emit)
        button.revoke_clicked.connect(self.revoke_user)
        button.show()

    def add_user_invitation(self, email, invite_addr):
        button = UserInvitationButton(email, invite_addr)
        self.layout_users.addWidget(button)
        button.greet_clicked.connect(self.greet_user)
        button.cancel_clicked.connect(self.cancel_invitation)
        button.show()

    def greet_user(self, token):
        GreetUserWidget.show_modal(core=self.core,
                                   jobs_ctx=self.jobs_ctx,
                                   token=token,
                                   parent=self,
                                   on_finished=self.reset)

    def cancel_invitation(self, token):
        r = ask_question(
            self,
            _("TEXT_USER_INVITE_CANCEL_INVITE_QUESTION_TITLE"),
            _("TEXT_USER_INVITE_CANCEL_INVITE_QUESTION_CONTENT"),
            [_("TEXT_USER_INVITE_CANCEL_INVITE_ACCEPT"),
             _("ACTION_NO")],
        )
        if r != _("TEXT_USER_INVITE_CANCEL_INVITE_ACCEPT"):
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "cancel_invitation_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "cancel_invitation_error", QtToTrioJob),
            _do_cancel_invitation,
            core=self.core,
            token=token,
        )

    def _on_revoke_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        user_info = job.ret
        show_info(
            self,
            _("TEXT_USER_REVOKE_SUCCESS_user").format(
                user=user_info.short_user_display))
        for i in range(self.layout_users.count()):
            item = self.layout_users.itemAt(i)
            if item:
                button = item.widget()
                if (button and isinstance(button, UserButton)
                        and button.user_info.user_id == user_info.user_id):
                    button.user_info = user_info

    def _on_revoke_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        status = job.status
        if status == "already_revoked":
            errmsg = _("TEXT_USER_REVOCATION_USER_ALREADY_REVOKED")
        elif status == "not_found":
            errmsg = _("TEXT_USER_REVOCATION_USER_NOT_FOUND")
        elif status == "not_allowed":
            errmsg = _("TEXT_USER_REVOCATION_NOT_ENOUGH_PERMISSIONS")
        elif status == "offline":
            errmsg = _("TEXT_USER_REVOCATION_BACKEND_OFFLINE")
        else:
            errmsg = _("TEXT_USER_REVOCATION_UNKNOWN_FAILURE")
        show_error(self, errmsg, exception=job.exc)

    def revoke_user(self, user_info):
        result = ask_question(
            self,
            _("TEXT_USER_REVOCATION_TITLE"),
            _("TEXT_USER_REVOCATION_INSTRUCTIONS_user").format(
                user=user_info.short_user_display),
            [_("ACTION_USER_REVOCATION_CONFIRM"),
             _("ACTION_CANCEL")],
        )
        if result != _("ACTION_USER_REVOCATION_CONFIRM"):
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "revoke_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "revoke_error", QtToTrioJob),
            _do_revoke_user,
            core=self.core,
            user_info=user_info,
        )

    def _flush_users_list(self):
        self.users = []
        while self.layout_users.count() != 0:
            item = self.layout_users.takeAt(0)
            if item:
                w = item.widget()
                self.layout_users.removeWidget(w)
                w.hide()
                w.setParent(None)

    def pagination(self, total: int, users_on_page: int):
        """Show/activate or hide/deactivate previous and next page button"""
        self.label_page_info.show()
        # Set plage of users displayed
        user_from = (self._page - 1) * USERS_PER_PAGE + 1
        user_to = user_from - 1 + users_on_page
        self.label_page_info.setText(
            _("TEXT_USERS_PAGE_INFO_page-pagetotal-userfrom-userto-usertotal").
            format(
                page=self._page,
                pagetotal=ceil(total / USERS_PER_PAGE),
                userfrom=user_from,
                userto=user_to,
                usertotal=total,
            ))
        if total > USERS_PER_PAGE:
            self.button_previous_page.show()
            self.button_next_page.show()
            self.button_previous_page.setEnabled(True)
            self.button_next_page.setEnabled(True)
            if self._page * USERS_PER_PAGE >= total:
                self.button_next_page.setEnabled(False)
            else:
                self.button_next_page.setEnabled(True)
            if self._page <= 1:
                self.button_previous_page.setEnabled(False)
            else:
                self.button_previous_page.setEnabled(True)
        else:
            self.button_previous_page.hide()
            self.button_next_page.hide()

    def _on_list_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        total, users, invitations = job.ret
        # Securing if page go to far
        if total == 0 and self._page > 1:
            self._page -= 1
            self.reset()
        self._flush_users_list()

        current_user = self.core.device.user_id
        for invitation in reversed(invitations):
            addr = BackendInvitationAddr.build(
                backend_addr=self.core.device.organization_addr,
                organization_id=self.core.device.organization_id,
                invitation_type=InvitationType.USER,
                token=invitation["token"],
            )
            self.add_user_invitation(invitation["claimer_email"], addr)
        for user_info in users:
            self.add_user(user_info=user_info,
                          is_current_user=current_user == user_info.user_id)
        self.spinner.hide()
        self.pagination(total=total, users_on_page=len(users))
        self.button_users_filter.setEnabled(True)
        self.line_edit_search.setEnabled(True)

    def _on_list_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        status = job.status
        if status in ["error", "offline"]:
            self._flush_users_list()
            label = QLabel(_("TEXT_USER_LIST_RETRIEVABLE_FAILURE"))
            label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
            self.layout_users.addWidget(label)
            return
        else:
            errmsg = _("TEXT_USER_LIST_RETRIEVABLE_FAILURE")
        self.spinner.hide()
        show_error(self, errmsg, exception=job.exc)

    def _on_cancel_invitation_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"
        self.reset()

    def _on_cancel_invitation_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        show_error(self, _("TEXT_INVITE_USER_CANCEL_ERROR"), exception=job.exc)

    def _on_invite_user_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        email = job.ret
        show_info(self,
                  _("TEXT_USER_INVITE_SUCCESS_email").format(email=email))
        self.reset()

    def _on_invite_user_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        status = job.status
        if status == "offline":
            errmsg = _("TEXT_INVITE_USER_INVITE_OFFLINE")
        else:
            errmsg = _("TEXT_INVITE_USER_INVITE_ERROR")

        show_error(self, errmsg, exception=job.exc)

    def reset(self):
        self.layout_users.clear()
        self.label_page_info.hide()
        self.button_users_filter.setEnabled(False)
        self.line_edit_search.setEnabled(False)
        self.button_previous_page.hide()
        self.button_next_page.hide()
        self.spinner.show()
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "list_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "list_error", QtToTrioJob),
            _do_list_users_and_invitations,
            core=self.core,
            page=self._page,
        )
Ejemplo n.º 3
0
class WorkspacesWidget(QWidget, Ui_WorkspacesWidget):
    fs_updated_qt = pyqtSignal(str, UUID)
    fs_synced_qt = pyqtSignal(str, UUID)
    entry_downsynced_qt = pyqtSignal(UUID, UUID)

    sharing_updated_qt = pyqtSignal(WorkspaceEntry, object)
    _workspace_created_qt = pyqtSignal(WorkspaceEntry)
    load_workspace_clicked = pyqtSignal(WorkspaceFS, FsPath, bool)
    workspace_reencryption_success = pyqtSignal(QtToTrioJob)
    workspace_reencryption_error = pyqtSignal(QtToTrioJob)
    workspace_reencryption_progress = pyqtSignal(EntryID, int, int)
    workspace_mounted = pyqtSignal(QtToTrioJob)
    workspace_unmounted = pyqtSignal(QtToTrioJob)

    rename_success = pyqtSignal(QtToTrioJob)
    rename_error = pyqtSignal(QtToTrioJob)
    create_success = pyqtSignal(QtToTrioJob)
    create_error = pyqtSignal(QtToTrioJob)
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)
    mount_success = pyqtSignal(QtToTrioJob)
    mount_error = pyqtSignal(QtToTrioJob)
    reencryption_needs_success = pyqtSignal(QtToTrioJob)
    reencryption_needs_error = pyqtSignal(QtToTrioJob)

    def __init__(self, core, jobs_ctx, event_bus, **kwargs):
        super().__init__(**kwargs)
        self.setupUi(self)

        self.core = core
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.reencrypting = set()

        self.layout_workspaces = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_workspaces)

        self.button_add_workspace.clicked.connect(
            self.create_workspace_clicked)
        self.button_goto_file.clicked.connect(self.goto_file_clicked)

        self.button_add_workspace.apply_style()
        self.button_goto_file.apply_style()

        self.fs_updated_qt.connect(self._on_fs_updated_qt)
        self.fs_synced_qt.connect(self._on_fs_synced_qt)
        self.entry_downsynced_qt.connect(self._on_entry_downsynced_qt)

        self.line_edit_search.textChanged.connect(self.on_workspace_filter)

        self.rename_success.connect(self.on_rename_success)
        self.rename_error.connect(self.on_rename_error)
        self.create_success.connect(self.on_create_success)
        self.create_error.connect(self.on_create_error)
        self.list_success.connect(self.on_list_success)
        self.list_error.connect(self.on_list_error)
        self.reencryption_needs_success.connect(
            self.on_reencryption_needs_success)
        self.reencryption_needs_error.connect(self.on_reencryption_needs_error)
        self.workspace_reencryption_progress.connect(
            self._on_workspace_reencryption_progress)
        self.workspace_mounted.connect(self._on_workspace_mounted)
        self.workspace_unmounted.connect(self._on_workspace_unmounted)
        self.workspace_reencryption_success.connect(
            self._on_workspace_reencryption_success)
        self.workspace_reencryption_error.connect(
            self._on_workspace_reencryption_error)

        self.reset_timer = QTimer()
        self.reset_timer.setInterval(1000)
        self.reset_timer.setSingleShot(True)
        self.reset_timer.timeout.connect(self.list_workspaces)

        self.sharing_updated_qt.connect(self._on_sharing_updated_qt)

        self._workspace_created_qt.connect(self._on_workspace_created_qt)

    def disconnect_all(self):
        pass

    def showEvent(self, event):
        self.event_bus.connect("fs.workspace.created",
                               self._on_workspace_created_trio)
        self.event_bus.connect("fs.entry.updated",
                               self._on_fs_entry_updated_trio)
        self.event_bus.connect("fs.entry.synced",
                               self._on_fs_entry_synced_trio)
        self.event_bus.connect("sharing.updated",
                               self._on_sharing_updated_trio)
        self.event_bus.connect("fs.entry.downsynced",
                               self._on_entry_downsynced_trio)
        self.reset()

    def hideEvent(self, event):
        try:
            self.event_bus.disconnect("fs.workspace.created",
                                      self._on_workspace_created_trio)
            self.event_bus.disconnect("fs.entry.updated",
                                      self._on_fs_entry_updated_trio)
            self.event_bus.disconnect("fs.entry.synced",
                                      self._on_fs_entry_synced_trio)
            self.event_bus.disconnect("sharing.updated",
                                      self._on_sharing_updated_trio)
            self.event_bus.disconnect("fs.entry.downsynced",
                                      self._on_entry_downsynced_trio)
        except ValueError:
            pass

    def goto_file_clicked(self):
        file_link = get_text_input(
            self,
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_TITLE"),
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_GOTO_FILE_LINK_PLACEHOLDER"),
            default_text="",
            button_text=_("ACTION_GOTO_FILE_LINK"),
        )
        if not file_link:
            return

        url = None
        try:
            url = BackendOrganizationFileLinkAddr.from_url(file_link)
        except ValueError as exc:
            show_error(self,
                       _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"),
                       exception=exc)
            return

        for item in self.layout_workspaces.items:
            w = item.widget()
            if w and w.workspace_fs.workspace_id == url.workspace_id:
                self.load_workspace(w.workspace_fs,
                                    path=url.path,
                                    selected=True)
                return
        show_error(self,
                   _("TEXT_WORKSPACE_GOTO_FILE_LINK_WORKSPACE_NOT_FOUND"))

    def on_workspace_filter(self, pattern):
        pattern = pattern.lower()
        for i in range(self.layout_workspaces.count()):
            item = self.layout_workspaces.itemAt(i)
            if item:
                w = item.widget()
                if pattern and pattern not in w.name.lower():
                    w.hide()
                else:
                    w.show()

    def load_workspace(self, workspace_fs, path=FsPath("/"), selected=False):
        self.load_workspace_clicked.emit(workspace_fs, path, selected)

    def on_create_success(self, job):
        pass

    def on_create_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_rename_success(self, job):
        workspace_button, workspace_name = job.ret
        workspace_button.reload_workspace_name(workspace_name)

    def on_rename_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_list_success(self, job):
        self.layout_workspaces.clear()
        workspaces = job.ret

        if not workspaces:
            self.line_edit_search.hide()
            label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
            label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
            self.layout_workspaces.addWidget(label)
            return

        self.line_edit_search.show()
        for count, workspace in enumerate(workspaces):
            workspace_fs, ws_entry, users_roles, files, timestamped = workspace

            try:
                self.add_workspace(workspace_fs,
                                   ws_entry,
                                   users_roles,
                                   files,
                                   timestamped=timestamped)
            except JobSchedulerNotAvailable:
                pass

    def on_list_error(self, job):
        self.layout_workspaces.clear()
        label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
        label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.layout_workspaces.addWidget(label)

    def on_mount_success(self, job):
        pass

    def on_mount_error(self, job):
        if isinstance(job.status,
                      MountpointConfigurationWorkspaceFSTimestampedError):
            show_error(self,
                       _("TEXT_WORKSPACE_CANNOT_MOUNT"),
                       exception=job.exc)

    def on_reencryption_needs_success(self, job):
        workspace_id, reencryption_needs = job.ret
        for idx in range(self.layout_workspaces.count()):
            widget = self.layout_workspaces.itemAt(idx).widget()
            if widget.workspace_fs.workspace_id == workspace_id:
                widget.reencryption_needs = reencryption_needs
                break

    def on_reencryption_needs_error(self, job):
        pass

    def add_workspace(self, workspace_fs, ws_entry, users_roles, files,
                      timestamped):

        # The Qt thread should never hit the core directly.
        # Synchronous calls can run directly in the job system
        # as they won't block the Qt loop for long
        workspace_name = self.jobs_ctx.run_sync(
            workspace_fs.get_workspace_name)
        button = WorkspaceButton(
            workspace_name=workspace_name,
            workspace_fs=workspace_fs,
            is_shared=len(users_roles) > 1,
            is_creator=ws_entry.role == WorkspaceRole.OWNER,
            files=files[:4],
            timestamped=timestamped,
        )
        self.layout_workspaces.addWidget(button)
        button.clicked.connect(self.load_workspace)
        button.share_clicked.connect(self.share_workspace)
        button.reencrypt_clicked.connect(self.reencrypt_workspace)
        button.delete_clicked.connect(self.delete_workspace)
        button.rename_clicked.connect(self.rename_workspace)
        button.remount_ts_clicked.connect(self.remount_workspace_ts)
        button.open_clicked.connect(self.open_workspace)
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "mount_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "mount_error", QtToTrioJob),
            _do_workspace_mount,
            core=self.core,
            workspace_id=workspace_fs.workspace_id,
        )
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "reencryption_needs_success",
                               QtToTrioJob),
            ThreadSafeQtSignal(self, "reencryption_needs_error", QtToTrioJob),
            _get_reencryption_needs,
            workspace_fs=workspace_fs,
        )

    def open_workspace(self, workspace_fs):
        self.open_workspace_file(workspace_fs, None)

    def open_workspace_file(self, workspace_fs, file_name):
        file_name = FsPath("/", file_name) if file_name else FsPath("/")

        # The Qt thread should never hit the core directly.
        # Synchronous calls can run directly in the job system
        # as they won't block the Qt loop for long
        path = self.jobs_ctx.run_sync(
            self.core.mountpoint_manager.get_path_in_mountpoint,
            workspace_fs.workspace_id,
            file_name,
            workspace_fs.timestamp
            if isinstance(workspace_fs, WorkspaceFSTimestamped) else None,
        )

        desktop.open_file(str(path))

    def remount_workspace_ts(self, workspace_fs):
        date, time = TimestampedWorkspaceWidget.exec_modal(
            workspace_fs=workspace_fs, jobs_ctx=self.jobs_ctx, parent=self)

        if not date or not time:
            return

        datetime = pendulum.datetime(
            date.year(),
            date.month(),
            date.day(),
            time.hour(),
            time.minute(),
            time.second(),
            tzinfo="local",
        )
        self.mount_workspace_timestamped(workspace_fs, datetime)

    def mount_workspace_timestamped(self, workspace_fs, timestamp):
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "workspace_mounted", QtToTrioJob),
            ThreadSafeQtSignal(self, "mount_error", QtToTrioJob),
            _do_workspace_mount,
            core=self.core,
            workspace_id=workspace_fs.workspace_id,
            timestamp=timestamp,
        )

    def delete_workspace(self, workspace_fs):
        if isinstance(workspace_fs, WorkspaceFSTimestamped):
            self.jobs_ctx.submit_job(
                ThreadSafeQtSignal(self, "workspace_unmounted", QtToTrioJob),
                ThreadSafeQtSignal(self, "mount_error", QtToTrioJob),
                _do_workspace_unmount,
                core=self.core,
                workspace_id=workspace_fs.workspace_id,
                timestamp=workspace_fs.timestamp,
            )
            return
        else:
            workspace_name = self.jobs_ctx.run_sync(
                workspace_fs.get_workspace_name)
            result = ask_question(
                self,
                _("TEXT_WORKSPACE_DELETE_TITLE"),
                _("TEXT_WORKSPACE_DELETE_INSTRUCTIONS_workspace").format(
                    workspace=workspace_name),
                [_("ACTION_DELETE_WORKSPACE_CONFIRM"),
                 _("ACTION_CANCEL")],
            )
            if result != _("ACTION_DELETE_WORKSPACE_CONFIRM"):
                return
            # Workspace deletion is not available yet (button should be hidden anyway)

    def rename_workspace(self, workspace_button):
        new_name = get_text_input(
            self,
            _("TEXT_WORKSPACE_RENAME_TITLE"),
            _("TEXT_WORKSPACE_RENAME_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_RENAME_PLACEHOLDER"),
            default_text=workspace_button.name,
            button_text=_("ACTION_WORKSPACE_RENAME_CONFIRM"),
        )
        if not new_name:
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "rename_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "rename_error", QtToTrioJob),
            _do_workspace_rename,
            core=self.core,
            workspace_id=workspace_button.workspace_fs.workspace_id,
            new_name=new_name,
            button=workspace_button,
        )

    def share_workspace(self, workspace_fs):
        WorkspaceSharingWidget.exec_modal(
            user_fs=self.core.user_fs,
            workspace_fs=workspace_fs,
            core=self.core,
            jobs_ctx=self.jobs_ctx,
            parent=self,
        )
        self.reset()

    def reencrypt_workspace(self, workspace_id, user_revoked, role_revoked):
        if workspace_id in self.reencrypting or (not user_revoked
                                                 and not role_revoked):
            return

        question = ""
        if user_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REVOKED"))
        if role_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REMOVED"))
        question += _("TEXT_WORKSPACE_NEED_REENCRYPTION_INSTRUCTIONS")

        r = ask_question(
            self,
            _("TEXT_WORKSPACE_NEED_REENCRYPTION_TITLE"),
            question,
            [_("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"),
             _("ACTION_CANCEL")],
        )
        if r != _("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"):
            return

        async def _reencrypt(on_progress, workspace_id):
            job = await self.core.user_fs.workspace_start_reencryption(
                workspace_id)
            while True:
                total, done = await job.do_one_batch(size=1)
                on_progress.emit(workspace_id, total, done)
                if total == done:
                    break
            return workspace_id

        self.reencrypting.add(workspace_id)

        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "workspace_reencryption_success",
                               QtToTrioJob),
            ThreadSafeQtSignal(self, "workspace_reencryption_error",
                               QtToTrioJob),
            _reencrypt,
            on_progress=ThreadSafeQtSignal(self,
                                           "workspace_reencryption_progress",
                                           EntryID, int, int),
            workspace_id=workspace_id,
        )

    def _on_workspace_reencryption_success(self, job):
        workspace_id = job.ret
        self.reencrypting.remove(workspace_id)

    def _on_workspace_reencryption_error(self, job):
        workspace_id = job.ret
        self.reencrypting.remove(workspace_id)

    def _on_workspace_reencryption_progress(self, workspace_id, total, done):
        for idx in range(self.layout_workspaces.count()):
            widget = self.layout_workspaces.itemAt(idx).widget()
            if widget.workspace_fs.workspace_id == workspace_id:
                if done == total:
                    widget.reencrypting = None
                else:
                    widget.reencrypting = (total, done)
                break

    def create_workspace_clicked(self):
        workspace_name = get_text_input(
            parent=self,
            title=_("TEXT_WORKSPACE_NEW_TITLE"),
            message=_("TEXT_WORKSPACE_NEW_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_NEW_PLACEHOLDER"),
            button_text=_("ACTION_WORKSPACE_NEW_CREATE"),
        )
        if not workspace_name:
            return
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "create_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "create_error", QtToTrioJob),
            _do_workspace_create,
            core=self.core,
            workspace_name=workspace_name,
        )

    def reset(self):
        if not self.reset_timer.isActive():
            self.reset_timer.start()
            self.list_workspaces()

    def list_workspaces(self):
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "list_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "list_error", QtToTrioJob),
            _do_workspace_list,
            core=self.core,
        )

    def _on_sharing_updated_trio(self, event, new_entry, previous_entry):
        self.sharing_updated_qt.emit(new_entry, previous_entry)

    def _on_sharing_updated_qt(self, new_entry, previous_entry):
        self.reset()

    def _on_workspace_created_trio(self, event, new_entry):
        self._workspace_created_qt.emit(new_entry)

    def _on_workspace_created_qt(self, workspace_entry):
        self.reset()

    def _on_fs_entry_synced_trio(self, event, id, workspace_id=None):
        self.fs_synced_qt.emit(event, id)

    def _on_fs_entry_updated_trio(self, event, workspace_id=None, id=None):
        if workspace_id and not id:
            self.fs_updated_qt.emit(event, workspace_id)

    def _on_entry_downsynced_trio(self, event, workspace_id=None, id=None):
        self.entry_downsynced_qt.emit(workspace_id, id)

    def _on_entry_downsynced_qt(self, workspace_id, id):
        self.reset()

    def _on_fs_synced_qt(self, event, id):
        self.reset()

    def _on_fs_updated_qt(self, event, workspace_id):
        self.reset()

    def _on_workspace_mounted(self, job):
        self.reset()

    def _on_workspace_unmounted(self, job):
        self.reset()
Ejemplo n.º 4
0
class UsersWidget(QWidget, Ui_UsersWidget):
    revoke_success = pyqtSignal(QtToTrioJob)
    revoke_error = pyqtSignal(QtToTrioJob)
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)
    invite_user_success = pyqtSignal(QtToTrioJob)
    invite_user_error = pyqtSignal(QtToTrioJob)
    cancel_invitation_success = pyqtSignal(QtToTrioJob)
    cancel_invitation_error = pyqtSignal(QtToTrioJob)
    filter_shared_workspaces_request = pyqtSignal(UserInfo)

    def __init__(self, core, jobs_ctx, event_bus, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setupUi(self)
        self.core = core
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.layout_users = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_users)
        self.button_add_user.apply_style()
        if core.device.is_admin:
            self.button_add_user.clicked.connect(self.invite_user)
        else:
            self.button_add_user.hide()
        self.search_timer = QTimer()
        self.search_timer.setInterval(300)
        self.search_timer.setSingleShot(True)
        self.search_timer.timeout.connect(self.on_filter)
        self.button_previous_page.clicked.connect(self.show_previous_page)
        self.button_next_page.clicked.connect(self.show_next_page)
        self.line_edit_search.textChanged.connect(self._search_changed)
        self.line_edit_search.clear_clicked.connect(self.on_filter)
        self.revoke_success.connect(self._on_revoke_success)
        self.revoke_error.connect(self._on_revoke_error)
        self.list_success.connect(self._on_list_success)
        self.list_error.connect(self._on_list_error)
        self.invite_user_success.connect(self._on_invite_user_success)
        self.invite_user_error.connect(self._on_invite_user_error)
        self.cancel_invitation_success.connect(
            self._on_cancel_invitation_success)
        self.cancel_invitation_error.connect(self._on_cancel_invitation_error)
        self.checkbox_filter_revoked.clicked.connect(self.reset)
        self.checkbox_filter_invitation.clicked.connect(self.reset)

    def show(self):
        self._page = 1
        self.reset()
        super().show()

    def show_next_page(self):
        self._page += 1
        self.on_filter(change_page=True)

    def show_previous_page(self):
        if self._page > 1:
            self._page -= 1
        self.on_filter(change_page=True)

    def _search_changed(self):
        self.search_timer.start()

    def on_filter(self, change_page=False):
        self.search_timer.stop()
        if change_page is False:
            self._page = 1
        self.reset()

    def invite_user(self):
        user_email = get_text_input(
            self,
            _("TEXT_USER_INVITE_EMAIL"),
            _("TEXT_USER_INVITE_EMAIL_INSTRUCTIONS"),
            placeholder=_("TEXT_USER_INVITE_EMAIL_PLACEHOLDER"),
            button_text=_("ACTION_USER_INVITE_DO_INVITE"),
            validator=validators.EmailValidator(),
        )
        if not user_email:
            return

        self.jobs_ctx.submit_job(
            self.invite_user_success,
            self.invite_user_error,
            _do_invite_user,
            core=self.core,
            email=user_email,
        )

    def add_user(self, user_info, is_current_user):
        button = UserButton(
            user_info=user_info,
            is_current_user=is_current_user,
            current_user_is_admin=self.core.device.is_admin,
        )
        self.layout_users.addWidget(button)
        button.filter_user_workspaces_clicked.connect(
            self.filter_shared_workspaces_request.emit)
        button.revoke_clicked.connect(self.revoke_user)
        button.show()

    def add_user_invitation(self, email, invite_addr):
        button = UserInvitationButton(email, invite_addr)
        self.layout_users.addWidget(button)
        button.greet_clicked.connect(self.greet_user)
        button.cancel_clicked.connect(self.cancel_invitation)
        button.show()

    def greet_user(self, token):
        GreetUserWidget.show_modal(core=self.core,
                                   jobs_ctx=self.jobs_ctx,
                                   token=token,
                                   parent=self,
                                   on_finished=self.reset)

    def cancel_invitation(self, token):
        r = ask_question(
            self,
            _("TEXT_USER_INVITE_CANCEL_INVITE_QUESTION_TITLE"),
            _("TEXT_USER_INVITE_CANCEL_INVITE_QUESTION_CONTENT"),
            [
                _("TEXT_USER_INVITE_CANCEL_INVITE_ACCEPT"),
                _("ACTION_ENABLE_TELEMETRY_REFUSE")
            ],
        )
        if r != _("TEXT_USER_INVITE_CANCEL_INVITE_ACCEPT"):
            return
        self.jobs_ctx.submit_job(
            self.cancel_invitation_success,
            self.cancel_invitation_error,
            _do_cancel_invitation,
            core=self.core,
            token=token,
        )

    def _on_revoke_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        user_info = job.ret
        SnackbarManager.inform(
            _("TEXT_USER_REVOKE_SUCCESS_user").format(
                user=user_info.short_user_display),
            timeout=5000,
        )
        for i in range(self.layout_users.count()):
            item = self.layout_users.itemAt(i)
            if item:
                button = item.widget()
                if (button and isinstance(button, UserButton)
                        and button.user_info.user_id == user_info.user_id):
                    button.user_info = user_info

    def _on_revoke_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        status = job.status
        if status == "already_revoked":
            errmsg = _("TEXT_USER_REVOCATION_USER_ALREADY_REVOKED")
        elif status == "not_found":
            errmsg = _("TEXT_USER_REVOCATION_USER_NOT_FOUND")
        elif status == "not_allowed":
            errmsg = _("TEXT_USER_REVOCATION_NOT_ENOUGH_PERMISSIONS")
        elif status == "offline":
            errmsg = _("TEXT_USER_REVOCATION_BACKEND_OFFLINE")
        else:
            errmsg = _("TEXT_USER_REVOCATION_UNKNOWN_FAILURE")
        show_error(self, errmsg, exception=job.exc)

    def revoke_user(self, user_info):
        result = ask_question(
            self,
            _("TEXT_USER_REVOCATION_TITLE"),
            _("TEXT_USER_REVOCATION_INSTRUCTIONS_user").format(
                user=user_info.short_user_display),
            [_("ACTION_USER_REVOCATION_CONFIRM"),
             _("ACTION_CANCEL")],
            oriented_question=True,
            dangerous_yes=True,
        )
        if result != _("ACTION_USER_REVOCATION_CONFIRM"):
            return
        self.jobs_ctx.submit_job(
            self.revoke_success,
            self.revoke_error,
            _do_revoke_user,
            core=self.core,
            user_info=user_info,
        )

    def pagination(self, total: int, users_on_page: int):
        """Show/activate or hide/deactivate previous and next page button"""
        self.label_page_info.show()
        # Set plage of users displayed
        user_from = (self._page - 1) * USERS_PER_PAGE + 1
        user_to = user_from - 1 + users_on_page
        self.label_page_info.setText(
            _("TEXT_USERS_PAGE_INFO_page-pagetotal-userfrom-userto-usertotal").
            format(
                page=self._page,
                pagetotal=ceil(total / USERS_PER_PAGE),
                userfrom=user_from,
                userto=user_to,
                usertotal=total,
            ))
        if total > USERS_PER_PAGE:
            self.button_previous_page.show()
            self.button_next_page.show()
            self.button_previous_page.setEnabled(True)
            self.button_next_page.setEnabled(True)
            if self._page * USERS_PER_PAGE >= total:
                self.button_next_page.setEnabled(False)
            else:
                self.button_next_page.setEnabled(True)
            if self._page <= 1:
                self.button_previous_page.setEnabled(False)
            else:
                self.button_previous_page.setEnabled(True)
        else:
            self.button_previous_page.hide()
            self.button_next_page.hide()

    def _on_list_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        self.layout_users.clear()

        total, users, invitations = job.ret
        # Securing if page go to far
        if total == 0 and self._page > 1:
            self._page -= 1
            self.reset()

        current_user = self.core.device.user_id

        for invitation in reversed(invitations):
            addr = BackendInvitationAddr.build(
                backend_addr=self.core.device.organization_addr.
                get_backend_addr(),
                organization_id=self.core.device.organization_id,
                invitation_type=InvitationType.USER,
                token=invitation["token"],
            )
            self.add_user_invitation(invitation["claimer_email"], addr)
        for user_info in users:
            self.add_user(user_info=user_info,
                          is_current_user=current_user == user_info.user_id)
        self.spinner.hide()

        self.pagination(total=total, users_on_page=len(users))
        self.line_edit_search.setFocus()

    def _on_list_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        self.layout_users.clear()

        status = job.status
        if status in ["error", "offline"]:
            label = QLabel(_("TEXT_USER_LIST_RETRIEVABLE_FAILURE"))
            label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
            self.layout_users.addWidget(label)
            return
        else:
            errmsg = _("TEXT_USER_LIST_RETRIEVABLE_FAILURE")
        self.spinner.hide()

        show_error(self, errmsg, exception=job.exc)

    def _on_cancel_invitation_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"
        SnackbarManager.inform(_("TEXT_USER_INVITATION_CANCELLED"))
        self.reset()

    def _on_cancel_invitation_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        show_error(self, _("TEXT_INVITE_USER_CANCEL_ERROR"), exception=job.exc)

    def _on_invite_user_success(self, job):
        assert job.is_finished()
        assert job.status == "ok"

        email, invitation_addr, email_sent_status = job.ret
        if email_sent_status == InvitationEmailSentStatus.SUCCESS:
            SnackbarManager.inform(
                _("TEXT_USER_INVITE_SUCCESS_email").format(email=email))
        elif email_sent_status == InvitationEmailSentStatus.BAD_RECIPIENT:
            show_info_copy_link(
                self,
                _("TEXT_EMAIL_FAILED_TO_SEND_TITLE"),
                _("TEXT_INVITE_USER_EMAIL_BAD_RECIPIENT_directlink").format(
                    directlink=invitation_addr),
                _("ACTION_COPY_ADDR"),
                str(invitation_addr),
            )
        else:
            show_info_copy_link(
                self,
                _("TEXT_EMAIL_FAILED_TO_SEND_TITLE"),
                _("TEXT_INVITE_USER_EMAIL_NOT_AVAILABLE_directlink").format(
                    directlink=invitation_addr),
                _("ACTION_COPY_ADDR"),
                str(invitation_addr),
            )

        self.reset()

    def _on_invite_user_error(self, job):
        assert job.is_finished()
        assert job.status != "ok"

        status = job.status
        if status == "offline":
            errmsg = _("TEXT_INVITE_USER_INVITE_OFFLINE")
        elif status == "already_member":
            errmsg = _("TEXT_INVITE_USER_ALREADY_MEMBER_ERROR")
        else:
            errmsg = _("TEXT_INVITE_USER_INVITE_ERROR")

        show_error(self, errmsg, exception=job.exc)

    def reset(self):
        self.label_page_info.hide()
        self.button_previous_page.hide()
        self.button_next_page.hide()
        self.spinner.show()
        pattern = self.line_edit_search.text()
        self.jobs_ctx.submit_job(
            self.list_success,
            self.list_error,
            _do_list_users_and_invitations,
            core=self.core,
            page=self._page,
            omit_revoked=self.checkbox_filter_revoked.isChecked(),
            omit_invitation=self.checkbox_filter_invitation.isChecked(),
            pattern=pattern,
        )
Ejemplo n.º 5
0
class DevicesWidget(QWidget, Ui_DevicesWidget):
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)

    def __init__(self, core, jobs_ctx, event_bus, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setupUi(self)
        self.jobs_ctx = jobs_ctx
        self.core = core
        self.event_bus = event_bus
        self.layout_devices = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_devices)
        self.devices = []
        self.button_add_device.clicked.connect(self.invite_new_device)
        self.button_add_device.apply_style()
        self.filter_timer = QTimer()
        self.filter_timer.setInterval(300)
        self.list_success.connect(self.on_list_success)
        self.list_error.connect(self.on_list_error)
        self.line_edit_search.textChanged.connect(self.filter_timer.start)
        self.filter_timer.timeout.connect(self.on_filter_timer_timeout)
        self.reset()

    def on_filter_timer_timeout(self):
        self.filter_devices(self.line_edit_search.text())

    def filter_devices(self, pattern):
        pattern = pattern.lower()
        for i in range(self.layout_devices.count()):
            item = self.layout_devices.itemAt(i)
            if item:
                w = item.widget()
                if pattern and pattern not in w.device_name.lower():
                    w.hide()
                else:
                    w.show()

    def change_password(self, device_name):
        PasswordChangeWidget.exec_modal(core=self.core, parent=self)

    def invite_new_device(self):
        InviteDeviceWidget.exec_modal(core=self.core,
                                      jobs_ctx=self.jobs_ctx,
                                      parent=self)
        self.reset()

    def add_device(self, device_name, is_current_device, certified_on):
        if device_name in self.devices:
            return
        button = DeviceButton(device_name, is_current_device, certified_on)
        self.layout_devices.addWidget(button)
        button.change_password_clicked.connect(self.change_password)
        button.show()
        self.devices.append(device_name)

    def on_list_success(self, job):
        devices = job.ret
        current_device = self.core.device
        self.devices = []
        self.layout_devices.clear()
        for device in devices:
            device_name = device.device_id.device_name
            self.add_device(
                device_name,
                is_current_device=device_name ==
                current_device.device_id.device_name,
                certified_on=device.timestamp,
            )

    def on_list_error(self, job):
        pass

    def reset(self):
        self.jobs_ctx.submit_job(
            ThreadSafeQtSignal(self, "list_success", QtToTrioJob),
            ThreadSafeQtSignal(self, "list_error", QtToTrioJob),
            _do_list_devices,
            core=self.core,
        )
Ejemplo n.º 6
0
class WorkspacesWidget(QWidget, Ui_WorkspacesWidget):
    REFRESH_WORKSPACES_LIST_DELAY = 1  # 1s

    load_workspace_clicked = pyqtSignal(WorkspaceFS, FsPath, bool)
    workspace_reencryption_success = pyqtSignal(QtToTrioJob)
    workspace_reencryption_error = pyqtSignal(QtToTrioJob)
    workspace_reencryption_progress = pyqtSignal(EntryID, int, int)
    mountpoint_state_updated = pyqtSignal(object, object)

    rename_success = pyqtSignal(QtToTrioJob)
    rename_error = pyqtSignal(QtToTrioJob)
    create_success = pyqtSignal(QtToTrioJob)
    create_error = pyqtSignal(QtToTrioJob)
    list_success = pyqtSignal(QtToTrioJob)
    list_error = pyqtSignal(QtToTrioJob)
    mount_success = pyqtSignal(QtToTrioJob)
    mount_error = pyqtSignal(QtToTrioJob)
    unmount_success = pyqtSignal(QtToTrioJob)
    unmount_error = pyqtSignal(QtToTrioJob)
    reencryption_needs_success = pyqtSignal(QtToTrioJob)
    reencryption_needs_error = pyqtSignal(QtToTrioJob)
    ignore_success = pyqtSignal(QtToTrioJob)
    ignore_error = pyqtSignal(QtToTrioJob)
    file_open_success = pyqtSignal(QtToTrioJob)
    file_open_error = pyqtSignal(QtToTrioJob)

    def __init__(self, core, jobs_ctx, event_bus, **kwargs):
        super().__init__(**kwargs)
        self.setupUi(self)

        self.core = core
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.reencrypting = set()
        self.disabled_workspaces = self.core.config.disabled_workspaces
        self.workspace_button_mapping = {}

        self.layout_workspaces = FlowLayout(spacing=40)
        self.layout_content.addLayout(self.layout_workspaces)

        if self.core.device.is_outsider:
            self.button_add_workspace.hide()
        else:
            self.button_add_workspace.clicked.connect(
                self.create_workspace_clicked)
        self.button_goto_file.clicked.connect(self.goto_file_clicked)

        self.button_add_workspace.apply_style()
        self.button_goto_file.apply_style()

        self.search_timer = QTimer()
        self.search_timer.setInterval(300)
        self.search_timer.setSingleShot(True)
        self.search_timer.timeout.connect(self.on_workspace_filter)

        self.line_edit_search.textChanged.connect(self.search_timer.start)
        self.line_edit_search.clear_clicked.connect(self.search_timer.start)

        self.rename_success.connect(self.on_rename_success)
        self.rename_error.connect(self.on_rename_error)
        self.create_success.connect(self.on_create_success)
        self.create_error.connect(self.on_create_error)
        self.list_success.connect(self.on_list_success)
        self.list_error.connect(self.on_list_error)
        self.reencryption_needs_success.connect(
            self.on_reencryption_needs_success)
        self.reencryption_needs_error.connect(self.on_reencryption_needs_error)
        self.workspace_reencryption_progress.connect(
            self._on_workspace_reencryption_progress)
        self.mount_success.connect(self.on_mount_success)
        self.mount_error.connect(self.on_mount_error)
        self.unmount_success.connect(self.on_unmount_success)
        self.unmount_error.connect(self.on_unmount_error)
        self.file_open_success.connect(self._on_file_open_success)
        self.file_open_error.connect(self._on_file_open_error)

        self.workspace_reencryption_success.connect(
            self._on_workspace_reencryption_success)
        self.workspace_reencryption_error.connect(
            self._on_workspace_reencryption_error)

        self.filter_remove_button.clicked.connect(self.remove_user_filter)
        self.filter_remove_button.apply_style()

        self.filter_user_info = None
        self.filter_layout_widget.hide()

    def remove_user_filter(self):
        self.filter_user_info = None
        self.filter_layout_widget.hide()
        self.reset()

    def set_user_info(self, user_info):
        self.filter_user_info = user_info
        self.filter_layout_widget.show()
        self.filter_label.setText(
            _("TEXT_WORKSPACE_FILTERED_user").format(
                user=user_info.short_user_display))

    def disconnect_all(self):
        pass

    def showEvent(self, event):
        self.event_bus.connect(CoreEvent.FS_WORKSPACE_CREATED,
                               self._on_workspace_created)
        self.event_bus.connect(CoreEvent.FS_ENTRY_UPDATED,
                               self._on_fs_entry_updated)
        self.event_bus.connect(CoreEvent.FS_ENTRY_SYNCED,
                               self._on_fs_entry_synced)
        self.event_bus.connect(CoreEvent.SHARING_UPDATED,
                               self._on_sharing_updated)
        self.event_bus.connect(CoreEvent.FS_ENTRY_DOWNSYNCED,
                               self._on_entry_downsynced)
        self.event_bus.connect(CoreEvent.MOUNTPOINT_STARTED,
                               self._on_mountpoint_started)
        self.event_bus.connect(CoreEvent.MOUNTPOINT_STOPPED,
                               self._on_mountpoint_stopped)
        self.reset()

    def hideEvent(self, event):
        try:
            self.event_bus.disconnect(CoreEvent.FS_WORKSPACE_CREATED,
                                      self._on_workspace_created)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_UPDATED,
                                      self._on_fs_entry_updated)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_SYNCED,
                                      self._on_fs_entry_synced)
            self.event_bus.disconnect(CoreEvent.SHARING_UPDATED,
                                      self._on_sharing_updated)
            self.event_bus.disconnect(CoreEvent.FS_ENTRY_DOWNSYNCED,
                                      self._on_entry_downsynced)
            self.event_bus.disconnect(CoreEvent.MOUNTPOINT_STARTED,
                                      self._on_mountpoint_started)
            self.event_bus.disconnect(CoreEvent.MOUNTPOINT_STOPPED,
                                      self._on_mountpoint_stopped)
        except ValueError:
            pass

    def has_workspaces_displayed(self):
        return self.layout_workspaces.count() >= 1 and isinstance(
            self.layout_workspaces.itemAt(0).widget(), WorkspaceButton)

    def goto_file_clicked(self):
        file_link = get_text_input(
            self,
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_TITLE"),
            _("TEXT_WORKSPACE_GOTO_FILE_LINK_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_GOTO_FILE_LINK_PLACEHOLDER"),
            default_text="",
            button_text=_("ACTION_GOTO_FILE_LINK"),
        )
        if not file_link:
            return

        try:
            addr = BackendOrganizationFileLinkAddr.from_url(
                file_link, allow_http_redirection=True)
        except ValueError as exc:
            show_error(self,
                       _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"),
                       exception=exc)
            return

        button = self.get_workspace_button(addr.workspace_id)
        if button is not None:
            try:
                path = button.workspace_fs.decrypt_file_link_path(addr)
            except ValueError as exc:
                show_error(self,
                           _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"),
                           exception=exc)
                return
            self.load_workspace(button.workspace_fs, path=path, selected=True)
            return

        show_error(self,
                   _("TEXT_WORKSPACE_GOTO_FILE_LINK_WORKSPACE_NOT_FOUND"))

    def on_workspace_filter(self):
        self.refresh_workspace_layout()

    def load_workspace(self, workspace_fs, path=FsPath("/"), selected=False):
        self.load_workspace_clicked.emit(workspace_fs, path, selected)

    def on_create_success(self, job):
        self.remove_user_filter()

    def on_create_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_CREATE_NEW_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_rename_success(self, job):
        workspace_button, workspace_name = job.ret
        if workspace_button:
            workspace_button.reload_workspace_name(workspace_name)

    def on_rename_error(self, job):
        if job.status == "invalid-name":
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_INVALID_NAME"),
                       exception=job.exc)
        else:
            show_error(self,
                       _("TEXT_WORKSPACE_RENAME_UNKNOWN_ERROR"),
                       exception=job.exc)

    def on_list_success(self, job):
        # Hide the spinner in case it was visible
        self.spinner.hide()
        workspaces = job.ret

        # Use temporary dict to update the workspace mapping
        new_mapping = {}
        old_mapping = dict(self.workspace_button_mapping)

        # Loop over the resulting workspaces
        for workspace_fs, workspace_name, ws_entry, users_roles, files, timestamped in workspaces:

            # Pop button from existing mapping
            key = (workspace_fs.workspace_id,
                   getattr(workspace_fs, "timestamp", None))
            button = old_mapping.pop(key, None)

            # Retrieve current role for ourself
            user_id = workspace_fs.device.user_id
            current_role = users_roles.get(user_id)

            # Create and bind button if it doesn't exist
            if button is None:
                button = WorkspaceButton(workspace_fs, parent=self)
                button.clicked.connect(self.load_workspace)
                if self.core.device.is_outsider:
                    button.button_share.hide()
                elif current_role in (WorkspaceRole.READER,
                                      WorkspaceRole.CONTRIBUTOR):
                    button.button_share.hide()
                else:
                    button.share_clicked.connect(self.share_workspace)
                button.reencrypt_clicked.connect(self.reencrypt_workspace)
                button.delete_clicked.connect(self.delete_workspace)
                button.rename_clicked.connect(self.rename_workspace)
                button.remount_ts_clicked.connect(self.remount_workspace_ts)
                button.open_clicked.connect(self.open_workspace)
                button.switch_clicked.connect(self._on_switch_clicked)

            # Apply new state
            button.apply_state(
                workspace_name=workspace_name,
                workspace_fs=workspace_fs,
                users_roles=users_roles,
                is_mounted=self.is_workspace_mounted(workspace_fs.workspace_id,
                                                     None),
                files=files[:4],
                timestamped=timestamped,
            )

            # Use this opportunity to trigger the `get_reencryption_needs` routine
            if button.is_owner:
                try:
                    self.jobs_ctx.submit_job(
                        self.reencryption_needs_success,
                        self.reencryption_needs_error,
                        _get_reencryption_needs,
                        workspace_fs=workspace_fs,
                    )
                except JobSchedulerNotAvailable:
                    pass

            # Add the button to the new mapping
            # Note that the order of insertion matters as it corresponds to the order in which
            # the workspaces are displayed.
            new_mapping[key] = button

        # Set the new mapping
        self.workspace_button_mapping = new_mapping

        # Refresh the layout, taking the filtering into account
        self.refresh_workspace_layout()

        # Dereference the old buttons
        for button in old_mapping.values():
            button.setParent(None)

    def refresh_workspace_layout(self):
        # This user has no workspaces yet
        if not self.workspace_button_mapping:
            self.layout_workspaces.clear()
            self.line_edit_search.hide()
            label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
            label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
            self.layout_workspaces.addWidget(label)
            return

        # Make sure the search bar is visible
        self.line_edit_search.show()

        # Get info for both filters
        name_filter = self.line_edit_search.text().lower() or None
        user_filter = self.filter_user_info and self.filter_user_info.user_id

        # Remove all widgets and add them back in order to make sure the order is always correct
        self.layout_workspaces.pop_all()

        # Loop over buttons
        for button in self.workspace_button_mapping.values():
            # Filter by name
            if name_filter is not None and name_filter not in button.name.str.lower(
            ):
                continue
            # Filter by user
            if user_filter is not None and user_filter not in button.users_roles:
                continue
            # Show and add widget to the layout
            button.show()
            self.layout_workspaces.addWidget(button)

        # Force the layout to update
        self.layout_workspaces.update()

    def on_list_error(self, job):
        self.spinner.hide()
        self.layout_workspaces.clear()
        label = QLabel(_("TEXT_WORKSPACE_NO_WORKSPACES"))
        label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.layout_workspaces.addWidget(label)

    def on_mount_success(self, job):
        self.reset()

    def on_mount_error(self, job):
        if isinstance(job.exc, MountpointError):
            workspace_id = job.arguments.get("workspace_id")
            timestamp = job.arguments.get("timestamp")
            wb = self.get_workspace_button(workspace_id, timestamp)
            if wb:
                wb.set_mountpoint_state(False)
            if isinstance(job.exc, MountpointNoDriveAvailable):
                show_error(self,
                           _("TEXT_WORKSPACE_CANNOT_MOUNT_NO_DRIVE"),
                           exception=job.exc)
            else:
                show_error(self,
                           _("TEXT_WORKSPACE_CANNOT_MOUNT"),
                           exception=job.exc)

    def on_unmount_success(self, job):
        self.reset()

    def on_unmount_error(self, job):
        if isinstance(job.exc, MountpointError):
            show_error(self,
                       _("TEXT_WORKSPACE_CANNOT_UNMOUNT"),
                       exception=job.exc)

    def on_reencryption_needs_success(self, job):
        workspace_id, reencryption_needs = job.ret
        button = self.get_workspace_button(workspace_id)
        if button is not None:
            button.reencryption_needs = reencryption_needs

    def on_reencryption_needs_error(self, job):
        pass

    def fix_legacy_workspace_names(self, workspace_fs, workspace_name):
        # Temporary code to fix the workspace names edited by
        # the previous naming policy (the userfs used to add
        # `(shared by <device>)` at the end of the workspace name)
        token = " (shared by "
        if token in workspace_name:
            workspace_name, *_ = workspace_name.split(token)
            self.jobs_ctx.submit_job(
                self.ignore_success,
                self.ignore_error,
                _do_workspace_rename,
                core=self.core,
                workspace_id=workspace_fs.workspace_id,
                new_name=workspace_name,
                button=None,
            )

    def _on_switch_clicked(self, state, workspace_fs, timestamp):
        if state:
            self.mount_workspace(workspace_fs.workspace_id, timestamp)
        else:
            self.unmount_workspace(workspace_fs.workspace_id, timestamp)
        if not timestamp:
            self.update_workspace_config(workspace_fs.workspace_id, state)

    def open_workspace(self, workspace_fs):
        self.open_workspace_file(workspace_fs, None)

    def open_workspace_file(self, workspace_fs, file_name):
        file_name = FsPath("/", file_name) if file_name else FsPath("/")

        try:
            path = self.core.mountpoint_manager.get_path_in_mountpoint(
                workspace_fs.workspace_id,
                file_name,
                workspace_fs.timestamp
                if isinstance(workspace_fs, WorkspaceFSTimestamped) else None,
            )
            self.jobs_ctx.submit_job(self.file_open_success,
                                     self.file_open_error,
                                     desktop.open_files_job, [path])
        except MountpointNotMounted:
            # The mountpoint has been umounted in our back, nothing left to do
            show_error(
                self,
                _("TEXT_FILE_OPEN_ERROR_file").format(file=str(file_name)))

    def _on_file_open_success(self, job):
        status, paths = job.ret
        if not status:
            show_error(
                self,
                _("TEXT_FILE_OPEN_ERROR_file").format(file=paths[0].name))

    def _on_file_open_error(self, job):
        logger.error("Failed to open the workspace in the explorer")

    def remount_workspace_ts(self, workspace_fs):
        def _on_finished(date, time):
            if not date or not time:
                return

            datetime = pendulum.local(date.year(), date.month(), date.day(),
                                      time.hour(), time.minute(),
                                      time.second())
            self.mount_workspace(workspace_fs.workspace_id, datetime)

        TimestampedWorkspaceWidget.show_modal(workspace_fs=workspace_fs,
                                              jobs_ctx=self.jobs_ctx,
                                              parent=self,
                                              on_finished=_on_finished)

    def mount_workspace(self, workspace_id, timestamp=None):
        # In successful cases, the events MOUNTPOINT_STARTED
        # will take care of refreshing the state of the button,
        # the mount_success signal is not connected to anything but
        # is being kept for potential testing purposes
        self.jobs_ctx.submit_job(
            self.mount_success,
            self.mount_error,
            _do_workspace_mount,
            core=self.core,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )

    def unmount_workspace(self, workspace_id, timestamp=None):
        # In successful cases, the event MOUNTPOINT_STOPPED
        # will take care of refreshing the state of the button,
        # the unmount_success signal is not connected to anything but
        # is being kept for potential testing purposes
        self.jobs_ctx.submit_job(
            self.unmount_success,
            self.unmount_error,
            _do_workspace_unmount,
            core=self.core,
            workspace_id=workspace_id,
            timestamp=timestamp,
        )

    def update_workspace_config(self, workspace_id, state):
        if state:
            self.disabled_workspaces -= {workspace_id}
        else:
            self.disabled_workspaces |= {workspace_id}
        self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED,
                            disabled_workspaces=self.disabled_workspaces)

    def is_workspace_mounted(self, workspace_id, timestamp=None):
        return self.core.mountpoint_manager.is_workspace_mounted(
            workspace_id, timestamp)

    def delete_workspace(self, workspace_fs):
        if isinstance(workspace_fs, WorkspaceFSTimestamped):
            self.unmount_workspace(workspace_fs.workspace_id,
                                   workspace_fs.timestamp)
            return
        else:
            workspace_name = workspace_fs.get_workspace_name()
            result = ask_question(
                self,
                _("TEXT_WORKSPACE_DELETE_TITLE"),
                _("TEXT_WORKSPACE_DELETE_INSTRUCTIONS_workspace").format(
                    workspace=workspace_name),
                [_("ACTION_DELETE_WORKSPACE_CONFIRM"),
                 _("ACTION_CANCEL")],
            )
            if result != _("ACTION_DELETE_WORKSPACE_CONFIRM"):
                return
            # Workspace deletion is not available yet (button should be hidden anyway)

    def rename_workspace(self, workspace_button):
        new_name = get_text_input(
            self,
            _("TEXT_WORKSPACE_RENAME_TITLE"),
            _("TEXT_WORKSPACE_RENAME_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_RENAME_PLACEHOLDER"),
            default_text=workspace_button.name.str,
            button_text=_("ACTION_WORKSPACE_RENAME_CONFIRM"),
            validator=validators.WorkspaceNameValidator(),
        )
        if not new_name:
            return
        self.jobs_ctx.submit_job(
            self.rename_success,
            self.rename_error,
            _do_workspace_rename,
            core=self.core,
            workspace_id=workspace_button.workspace_fs.workspace_id,
            new_name=new_name,
            button=workspace_button,
        )

    def on_sharing_closing(self, has_changes):
        if has_changes:
            self.reset()

    def share_workspace(self, workspace_fs):
        WorkspaceSharingWidget.show_modal(
            user_fs=self.core.user_fs,
            workspace_fs=workspace_fs,
            core=self.core,
            jobs_ctx=self.jobs_ctx,
            parent=self,
            on_finished=self.on_sharing_closing,
        )

    def reencrypt_workspace(self, workspace_id, user_revoked, role_revoked,
                            reencryption_already_in_progress):
        if workspace_id in self.reencrypting or (
                not user_revoked and not role_revoked
                and not reencryption_already_in_progress):
            return

        question = ""
        if user_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REVOKED"))
        if role_revoked:
            question += "{}\n".format(
                _("TEXT_WORKSPACE_NEED_REENCRYPTION_BECAUSE_USER_REMOVED"))
        question += _("TEXT_WORKSPACE_NEED_REENCRYPTION_INSTRUCTIONS")

        r = ask_question(
            self,
            _("TEXT_WORKSPACE_NEED_REENCRYPTION_TITLE"),
            question,
            [_("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"),
             _("ACTION_CANCEL")],
        )
        if r != _("ACTION_WORKSPACE_REENCRYPTION_CONFIRM"):
            return

        @contextmanager
        def _handle_fs_errors():
            try:
                yield
            except FSBackendOfflineError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="offline-backend",
                                     origin=exc)
            except FSWorkspaceNoAccess as exc:
                raise JobResultError(ret=workspace_id,
                                     status="access-error",
                                     origin=exc)
            except FSWorkspaceNotFoundError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="not-found",
                                     origin=exc)
            except FSError as exc:
                raise JobResultError(ret=workspace_id,
                                     status="fs-error",
                                     origin=exc)

        async def _reencrypt(on_progress, workspace_id):
            with _handle_fs_errors():
                if reencryption_already_in_progress:
                    job = await self.core.user_fs.workspace_continue_reencryption(
                        workspace_id)
                else:
                    job = await self.core.user_fs.workspace_start_reencryption(
                        workspace_id)
            while True:
                with _handle_fs_errors():
                    total, done = await job.do_one_batch()
                on_progress.emit(workspace_id, total, done)
                if total == done:
                    break
            return workspace_id

        self.reencrypting.add(workspace_id)

        # Initialize progress to 0 percent
        workspace_button = self.get_workspace_button(workspace_id, None)
        workspace_button.reencrypting = 1, 0

        self.jobs_ctx.submit_job(
            self.workspace_reencryption_success,
            self.workspace_reencryption_error,
            _reencrypt,
            on_progress=self.workspace_reencryption_progress,
            workspace_id=workspace_id,
        )

    def _on_workspace_reencryption_success(self, job):
        workspace_id = job.arguments["workspace_id"]
        workspace_button = self.get_workspace_button(workspace_id, None)
        workspace_button.reencryption_needs = None
        workspace_button.reencrypting = None
        self.reencrypting.remove(workspace_id)

    def _on_workspace_reencryption_error(self, job):
        workspace_id = job.arguments["workspace_id"]
        workspace_button = self.get_workspace_button(workspace_id, None)
        workspace_button.reencrypting = None
        self.reencrypting.remove(workspace_id)
        if job.is_cancelled():
            return
        if job.status == "offline-backend":
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_OFFLINE_ERROR")
        elif job.status == "access-error":
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_ACCESS_ERROR")
        elif job.status == "not-found":
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_NOT_FOUND_ERROR")
        elif job.status == "fs-error":
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_FS_ERROR")
        else:
            err_msg = _("TEXT_WORKSPACE_REENCRYPT_UNKOWN_ERROR")
        show_error(self, err_msg, exception=job.exc)

    def get_workspace_button(self, workspace_id, timestamp=None):
        key = (workspace_id, timestamp)
        return self.workspace_button_mapping.get(key)

    def _on_workspace_reencryption_progress(self, workspace_id, total, done):
        wb = self.get_workspace_button(workspace_id, None)
        if done == total:
            wb.reencrypting = None
        else:
            wb.reencrypting = (total, done)

    def create_workspace_clicked(self):
        workspace_name = get_text_input(
            parent=self,
            title=_("TEXT_WORKSPACE_NEW_TITLE"),
            message=_("TEXT_WORKSPACE_NEW_INSTRUCTIONS"),
            placeholder=_("TEXT_WORKSPACE_NEW_PLACEHOLDER"),
            button_text=_("ACTION_WORKSPACE_NEW_CREATE"),
            validator=validators.WorkspaceNameValidator(),
        )
        if not workspace_name:
            return
        self.jobs_ctx.submit_job(
            self.create_success,
            self.create_error,
            _do_workspace_create,
            core=self.core,
            workspace_name=workspace_name,
        )

    def reset(self):
        self.list_workspaces()

    def list_workspaces(self):
        if not self.has_workspaces_displayed():
            self.layout_workspaces.clear()
            self.spinner.show()
        self.jobs_ctx.submit_throttled_job(
            "workspace_widget.list_workspaces",
            self.REFRESH_WORKSPACES_LIST_DELAY,
            self.list_success,
            self.list_error,
            _do_workspace_list,
            core=self.core,
        )

    def _on_sharing_updated(self, event, new_entry, previous_entry):
        self.reset()

    def _on_workspace_created(self, event, new_entry):
        self.reset()

    def _on_fs_entry_synced(self, event, id, workspace_id=None):
        self.reset()

    def _on_fs_entry_updated(self, event, workspace_id=None, id=None):
        assert id is not None
        if workspace_id and id == workspace_id:
            self.reset()

    def _on_entry_downsynced(self, event, workspace_id=None, id=None):
        self.reset()

    def _on_mountpoint_state_updated(self, workspace_id, timestamp):
        wb = self.get_workspace_button(workspace_id, timestamp)
        if wb:
            mounted = self.is_workspace_mounted(workspace_id, timestamp)
            wb.set_mountpoint_state(mounted)

    def _on_mountpoint_started(self, event, mountpoint, workspace_id,
                               timestamp):
        self._on_mountpoint_state_updated(workspace_id, timestamp)

    def _on_mountpoint_stopped(self, event, mountpoint, workspace_id,
                               timestamp):
        self._on_mountpoint_state_updated(workspace_id, timestamp)