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 __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 __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 __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.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)
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.button_add_device.clicked.connect(self.invite_device) self.button_add_device.apply_style() self.list_success.connect(self._on_list_success) self.list_error.connect(self._on_list_error) self.invite_success.connect(self._on_invite_success) self.invite_error.connect(self._on_invite_error)
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()
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)
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, )
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()
class DevicesWidget(QWidget, Ui_DevicesWidget): list_success = pyqtSignal(QtToTrioJob) list_error = pyqtSignal(QtToTrioJob) invite_success = pyqtSignal(QtToTrioJob) invite_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.button_add_device.clicked.connect(self.invite_device) self.button_add_device.apply_style() self.list_success.connect(self._on_list_success) self.list_error.connect(self._on_list_error) self.invite_success.connect(self._on_invite_success) self.invite_error.connect(self._on_invite_error) def show(self): self.reset() super().show() def invite_device(self): self.jobs_ctx.submit_job( ThreadSafeQtSignal(self, "invite_success", QtToTrioJob), ThreadSafeQtSignal(self, "invite_error", QtToTrioJob), _do_invite_device, core=self.core, ) def _on_invite_success(self, job): assert job.is_finished() assert job.status == "ok" GreetDeviceWidget.show_modal( core=self.core, jobs_ctx=self.jobs_ctx, invite_addr=job.ret, parent=self, on_finished=self.reset, ) def _on_invite_error(self, job): assert job.is_finished() assert job.status != "ok" status = job.status if status == "offline": errmsg = _("TEXT_INVITE_DEVICE_INVITE_OFFLINE") else: errmsg = _("TEXT_INVITE_DEVICE_INVITE_ERROR") show_error(self, errmsg, exception=job.exc) def add_device(self, device_info, is_current_device): button = DeviceButton(device_info, is_current_device) self.layout_devices.addWidget(button) button.show() def _on_list_success(self, job): assert job.is_finished() assert job.status == "ok" devices = job.ret current_device = self.core.device self.layout_devices.clear() for device in devices: self.add_device( device, is_current_device=current_device.device_id == device.device_id) self.spinner.hide() def _on_list_error(self, job): assert job.is_finished() assert job.status != "ok" status = job.status if status in ["error", "offline"]: self.layout_devices.clear() label = QLabel(_("TEXT_DEVICE_LIST_RETRIEVABLE_FAILURE")) label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) self.layout_devices.addWidget(label) self.spinner.hide() def reset(self): self.layout_devices.clear() self.spinner.show() self.jobs_ctx.submit_job( ThreadSafeQtSignal(self, "list_success", QtToTrioJob), ThreadSafeQtSignal(self, "list_error", QtToTrioJob), _do_list_devices, core=self.core, )
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, )
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, )
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)