diff --git a/cuegui/cuegui/Comments.py b/cuegui/cuegui/Comments.py index c24a9802e..aad8e7167 100644 --- a/cuegui/cuegui/Comments.py +++ b/cuegui/cuegui/Comments.py @@ -44,20 +44,18 @@ class CommentListDialog(QtWidgets.QDialog): def __init__(self, source, parent=None): """Initialize the dialog. - - @type source: Job or Host + @type source: List of Jobs or Hosts @param source: The source to get the comments from @type parent: QWidget @param parent: The dialog's parent""" QtWidgets.QDialog.__init__(self, parent) self.__source = source - self.__labelTitle = QtWidgets.QLabel(self.__source.data.name, self) - self.__splitter = QtWidgets.QSplitter(self) self.__splitter.setOrientation(QtCore.Qt.Vertical) self.__treeSubjects = QtWidgets.QTreeWidget(self) + self.__treeSubjects.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.__textSubject = QtWidgets.QLineEdit(self) self.__textMessage = QtWidgets.QTextEdit(self) @@ -68,16 +66,16 @@ def __init__(self, source, parent=None): self.__btnClose = QtWidgets.QPushButton("Close", self) self.setWindowTitle("Comments") - self.resize(600, 300) + self.resize(800, 400) self.__btnNew.setDefault(True) self.__treeSubjects.setHeaderLabels(["Subject", "User", "Date"]) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.__labelTitle) self.__splitter.addWidget(self.__treeSubjects) self.__group = QtWidgets.QGroupBox(self.__splitter) + self.__group.setTitle("Edit Comment") glayout = QtWidgets.QVBoxLayout() glayout.addWidget(self.__textSubject) glayout.addWidget(self.__textMessage) @@ -118,13 +116,16 @@ def __textEdited(self, text=None): def __close(self): if self.__btnSave.isEnabled(): if cuegui.Utils.questionBoxYesNo(self, - "Save Changes?", - "Do you want to save your changes?"): + "Save Changes?", + "Do you want to save your changes?"): self.__saveComment() self.close() def __saveComment(self): """Saves the new or selected comment""" + if not self.__textSubject.text(): + cuegui.Utils.showErrorMessageBox("Comment subject cannot be empty") + return if self.__btnSave.text() == SAVE_NEW: # If saving a new comment self.__addComment(self.__textSubject.text(), @@ -132,18 +133,19 @@ def __saveComment(self): self.refreshComments() else: # If saving a modified comment - if self.__treeSubjects.currentItem(): - comment = self.__treeSubjects.currentItem().getInstance() - comment.setSubject(str(self.__textSubject.text())) - comment.setMessage(str(self.__textMessage.toPlainText())) - self.__treeSubjects.currentItem().getInstance().save() + if self.__treeSubjects.selectedItems(): + for item in self.__treeSubjects.selectedItems(): + comment = item.getInstance() + comment.setSubject(str(self.__textSubject.text())) + comment.setMessage(str(self.__textMessage.toPlainText())) + comment.save() self.refreshComments() def __createNewComment(self): """Clears the dialog to create a new comment""" if not self.__treeSubjects.selectedItems() and \ - self.__textSubject.text() and \ - not self.__textMessage.toPlainText(): + self.__textSubject.text() and \ + not self.__textMessage.toPlainText(): self.__textMessage.setFocus(QtCore.Qt.OtherFocusReason) else: self.__textSubject.setText("") @@ -158,23 +160,38 @@ def __createNewComment(self): def __itemChanged(self): """when the current item changes this sets the bottom view and current - item""" + item. if the last items from the sources are identical, then they will be selected. + otherwise no item will be selected. If the user viewing items is not the same + as comment author, then items will be be read only.""" + # pylint: disable=unnecessary-lambda + types = map(lambda item: type(item), self.__treeSubjects.selectedItems()) if self.__treeSubjects.selectedItems(): - item = self.__treeSubjects.selectedItems()[0] - - if item.getInstance().user != cuegui.Utils.getUsername(): - self.__textSubject.setReadOnly(True) - self.__textMessage.setReadOnly(True) + if CommentSource in types: + self.__createNewComment() else: - self.__textSubject.setReadOnly(False) - self.__textMessage.setReadOnly(False) - - self.__textSubject.setText(item.getInstance().subject()) - self.__textMessage.setText(item.getInstance().message()) - self.__treeSubjects.setCurrentItem(item) - self.__btnSave.setText(SAVE_EDIT) - self.__btnSave.setEnabled(False) - self.__btnDel.setEnabled(True) + first_item = self.__treeSubjects.selectedItems()[0] + # pylint: disable=line-too-long + identical = all(item.getInstance().message() == first_item.getInstance().message() and + item.getInstance().subject() == first_item.getInstance().subject() + for item in self.__treeSubjects.selectedItems()) + read_only = any(item.getInstance().user() != cuegui.Utils.getUsername() + for item in self.__treeSubjects.selectedItems()) + if identical: + for item in self.__treeSubjects.selectedItems(): + item.setSelected(True) + if read_only: + self.__textSubject.setReadOnly(True) + self.__textMessage.setReadOnly(True) + else: + self.__textSubject.setReadOnly(False) + self.__textMessage.setReadOnly(False) + self.__textSubject.setText(first_item.getInstance().subject()) + self.__textMessage.setText(first_item.getInstance().message()) + self.__btnSave.setText(SAVE_EDIT) + self.__btnSave.setEnabled(False) + self.__btnDel.setEnabled(True) + else: + self.__createNewComment() else: self.__createNewComment() @@ -183,34 +200,62 @@ def __deleteSelectedComment(self): if not self.__treeSubjects.selectedItems(): return if cuegui.Utils.questionBoxYesNo(self, - "Confirm Delete", - "Delete the selected comment?"): + "Confirm Delete", + "Delete the selected comment?"): for item in self.__treeSubjects.selectedItems(): + parent = item.parent() + parent.removeChild(item) item.getInstance().delete() - self.__treeSubjects.takeTopLevelItem(self.__treeSubjects.indexOfTopLevelItem(item)) def refreshComments(self): """Clears and populates the comment list from the cuebot""" - comments = self.__source.getComments() + comments = {} + for source in self.__source: + comments[source.data.name] = source.getComments() self.__treeSubjects.clear() - for comment in comments: - item = Comment(comment) - item.setSizeHint(0, QtCore.QSize(300, 1)) - self.__treeSubjects.addTopLevelItem(item) + comments_length = 0 + for source in comments: + heading = CommentSource(source) + heading.setSizeHint(0, QtCore.QSize(500, 1)) + self.__treeSubjects.addTopLevelItem(heading) + for comment in comments[source]: + item = Comment(comment) + heading.addChild(item) + item.setSizeHint(0, QtCore.QSize(300, 1)) + comments_length += 1 self.__treeSubjects.resizeColumnToContents(0) - last_item = self.__treeSubjects.topLevelItem(len(comments) - 1) - if last_item: + self.__treeSubjects.expandAll() + + last_items = [] + for i in range(self.__treeSubjects.topLevelItemCount()): + comment_source = self.__treeSubjects.topLevelItem(i) + last_items.append(comment_source.child(comment_source.childCount()-1)) + identical = all(item.getInstance().message() == last_items[0].getInstance().message() and + item.getInstance().subject() == last_items[0].getInstance().subject() + for item in last_items) + read_only = any(elem.getInstance().user() != cuegui.Utils.getUsername() + for elem in last_items) + + if identical: self.__btnSave.setText(SAVE_EDIT) self.__btnSave.setEnabled(False) - last_item.setSelected(True) + for last_item in last_items: + last_item.setSelected(True) + if read_only: + self.__textSubject.setReadOnly(True) + self.__textMessage.setReadOnly(True) + else: + self.__textSubject.setReadOnly(False) + self.__textMessage.setReadOnly(False) else: self.__createNewComment() def __macroLoad(self): """Loads the defined comment macros from settings""" # pylint: disable=no-member + comments_macro = QtGui.qApp.settings.value("Comments", pickle.dumps({})) self.__macroList = pickle.loads( - str(QtGui.qApp.settings.value("Comments", pickle.dumps({})))) + comments_macro if isinstance(comments_macro, bytes) else comments_macro.encode('UTF-8')) # pylint: enable=no-member self.__macroRefresh() @@ -294,7 +339,8 @@ def __macroSelectDialog(self, action): return (str(result[0]), result[1]) def __addComment(self, subject, message): - self.__source.addComment(str(subject), str(message) or " ") + for source in self.__source: + source.addComment(str(subject), str(message) or " ") class CommentMacroDialog(QtWidgets.QDialog): @@ -343,11 +389,11 @@ def __init__(self, name="", subject="", message="", parent=None): def __save(self): """Validates and then exits from the dialog in success""" if list(self.values())[0] != "" and \ - list(self.values())[1] != "" and \ - list(self.values())[0] not in (PREDEFINED_COMMENT_HEADER, - PREDEFINED_COMMENT_ADD, - PREDEFINED_COMMENT_EDIT, - PREDEFINED_COMMENT_DELETE): + list(self.values())[1] != "" and \ + list(self.values())[0] not in (PREDEFINED_COMMENT_HEADER, + PREDEFINED_COMMENT_ADD, + PREDEFINED_COMMENT_EDIT, + PREDEFINED_COMMENT_DELETE): self.accept() def values(self): @@ -372,3 +418,16 @@ def __init__(self, comment): def getInstance(self): """returns the actual comment instance this widget is displaying""" return self.__comment + + +class CommentSource(QtWidgets.QTreeWidgetItem): + """A widget to represent the heading job/host name of the list of comments""" + def __init__(self, source): + QtWidgets.QTreeWidgetItem.__init__( + self, + [source]) + self.__source = source + + def getInstace(self): + """returns the actual comment instance this widget is displaying""" + return self.__source diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index a988538c3..177062d0b 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -67,7 +67,7 @@ LOGGER_LEVEL = "WARNING" EMAIL_SUBJECT_PREFIX = "cuemail: please check " -EMAIL_BODY_PREFIX = "Your PSTs request that you check " +EMAIL_BODY_PREFIX = "Your PSTs request that you check:\n" EMAIL_BODY_SUFFIX = "\n\n" EMAIL_DOMAIN = "" diff --git a/cuegui/cuegui/EmailDialog.py b/cuegui/cuegui/EmailDialog.py index f4f83387e..889c9b40e 100644 --- a/cuegui/cuegui/EmailDialog.py +++ b/cuegui/cuegui/EmailDialog.py @@ -20,7 +20,6 @@ from __future__ import print_function from __future__ import division -from builtins import str from builtins import map try: from email.MIMEText import MIMEText @@ -28,7 +27,6 @@ except ImportError: from email.mime.text import MIMEText from email.header import Header -import os # pwd is not available on Windows. # TODO(bcipriano) Remove this, not needed once user info can come directly from Cuebot. # (https://github.com/imageworks/OpenCue/issues/218) @@ -42,8 +40,6 @@ from PySide2 import QtGui from PySide2 import QtWidgets -import opencue - import cuegui.Constants import cuegui.Logger import cuegui.Utils @@ -51,128 +47,32 @@ logger = cuegui.Logger.getLogger(__file__) +SUBJ_LINE_TOO_LONG = 2 + class EmailDialog(QtWidgets.QDialog): """Dialog for emailing a job owner.""" - def __init__(self, job, parent=None): + def __init__(self, jobs, parent=None): QtWidgets.QDialog.__init__(self, parent) - try: - self.__frames = job.getFrames(state=[opencue.api.job_pb2.DEAD]) - except opencue.exception.CueException: - self.__frames = [] + job_names = ','.join(map(lambda job: job.data.name, jobs)) - self.setWindowTitle("Email For: %s" % job.data.name) + self.setWindowTitle("Email For: %s" % job_names) self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.setSizeGripEnabled(True) - self.setFixedSize(1000,600) + self.setFixedSize(1000, 600) - self.__email = EmailWidget(job, self) - self.__appendDeadFrameInfo(job) - self.__logView = LogViewWidget(job, self.__frames, self) + self.__email = EmailWidget(jobs, self) hlayout = QtWidgets.QHBoxLayout(self) hlayout.addWidget(self.__email) - hlayout.addWidget(self.__logView) self.__email.giveFocus() self.__email.send.connect(self.accept) self.__email.cancel.connect(self.reject) - def __appendDeadFrameInfo(self, job): - """Adds frame data to email body - @type job: job - @param job: The job to email about""" - if job.data.job_stats.dead_frames: - self.__email.appendToBody("\nFrames:") - i_total_render_time = 0 - i_total_retries = 0 - for frame in self.__frames: - self.__email.appendToBody("%s\t%s\tRuntime: %s\tRetries: %d" % ( - frame.data.name, - frame.state(), - cuegui.Utils.secondsToHHMMSS(frame.runTime()), frame.retries())) - i_total_render_time += frame.retries() * frame.runTime() - i_total_retries += frame.retries() - self.__email.appendToBody( - "\nEstimated Proc Hours: %0.2f\n\n" % (i_total_render_time / 3600.0)) - - -class LogViewWidget(QtWidgets.QWidget): - """Widget for displaying a log within the email dialog.""" - - def __init__(self, job, frames, parent=None): - QtWidgets.QWidget.__init__(self, parent) - QtWidgets.QVBoxLayout(self) - ly = self.layout() - - self.__job = job - self.__frames = frames - - self.__sel_frames = QtWidgets.QComboBox(self) - for frame in frames: - self.__sel_frames.addItem(frame.data.name) - - self.__txt_find = QtWidgets.QLineEdit(self) - - self.__txt_log = QtWidgets.QPlainTextEdit(self) - self.__txt_log.setWordWrapMode(QtGui.QTextOption.NoWrap) - self.__txt_log.ensureCursorVisible() - - if self.__frames: - self.switchLogEvent(self.__frames[0].data.name) - - ly.addWidget(QtWidgets.QLabel("Select Frame:", self)) - ly.addWidget(self.__sel_frames) - ly.addWidget(QtWidgets.QLabel("Find:", self)) - ly.addWidget(self.__txt_find) - ly.addWidget(self.__txt_log) - - # pylint: disable=no-member - self.__sel_frames.activated.connect(self.switchLogEvent) - self.__txt_find.returnPressed.connect(self.findEvent) - # pylint: enable=no-member - - # pylint: disable=inconsistent-return-statements - def __getFrame(self, name): - for frame in self.__frames: - if frame.data.name == name: - return frame - - def switchLogEvent(self, str_frame): - """Displays the log for the given frame.""" - # pylint: disable=broad-except - try: - self.__txt_log.clear() - log_file_path = cuegui.Utils.getFrameLogFile(self.__job, self.__getFrame(str_frame)) - fp = open(log_file_path, "r") - if os.path.getsize(log_file_path) > 1242880: - fp.seek(0, 2) - fp.seek(-1242880, 1) - # Bad characters in the log can cause the remainder of the log to be left out - # so ignore any invalid characters - self.__txt_log.appendPlainText(fp.read().decode('utf8', 'ignore')) - self.__txt_log.textCursor().movePosition(QtGui.QTextCursor.End) - self.__txt_log.appendPlainText("\n") - fp.close() - - except Exception as e: - list(map(logger.warning, cuegui.Utils.exceptionOutput(e))) - logger.info("error loading frame: %s, %s", str_frame, e) - - def findEvent(self): - """attempts to find the text from the find text box, - highlights and scrolls to it""" - document = self.__txt_log.document() - cursor = document.find( - str(self.__txt_find.text()).strip(), - self.__txt_log.textCursor().position(), - QtGui.QTextDocument.FindBackward) - if cursor.position() > 1: - self.__txt_log.setTextCursor(cursor) - class EmailWidget(QtWidgets.QWidget): """Widget for displaying an email form.""" @@ -180,27 +80,49 @@ class EmailWidget(QtWidgets.QWidget): send = QtCore.Signal() cancel = QtCore.Signal() - def __init__(self, job, parent=None): + def __init__(self, jobs, parent=None): QtWidgets.QWidget.__init__(self, parent=parent) - self.__job = job + self.__jobs = jobs # Temporary workaround when pwd library is not available (i.e. Windows). # TODO(bcipriano) Pull this info directly from Cuebot. # (https://github.com/imageworks/OpenCue/issues/218) + user_names = set() if 'pwd' in globals(): - user_name = pwd.getpwnam(job.username()).pw_gecos + for job in jobs: + user_names.add(pwd.getpwnam(job.username()).pw_gecos) else: - user_name = job.username() + for job in jobs: + user_names.add(job.username()) - __default_from = "%s-pst@%s" % (job.show(), cuegui.Constants.EMAIL_DOMAIN) - __default_to = "%s@%s" % (job.username(), cuegui.Constants.EMAIL_DOMAIN) - __default_cc = "%s-pst@%s" % (job.show(), cuegui.Constants.EMAIL_DOMAIN) + user_names = list(user_names) + if len(user_names) > 1: + user_names = ', '.join(user_names[:-1]) + (' and %s' % user_names[-1]) + else: + user_names = user_names[0] + + to_emails = set() + for job in jobs: + to_emails.add("%s@%s" % (job.username(), cuegui.Constants.EMAIL_DOMAIN)) + + __default_from = "%s-pst@%s" % (jobs[0].show(), cuegui.Constants.EMAIL_DOMAIN) + __default_to = ','.join(to_emails) + __default_cc = "%s-pst@%s" % (jobs[0].show(), cuegui.Constants.EMAIL_DOMAIN) __default_bcc = "" - __default_subject = "%s%s" % (cuegui.Constants.EMAIL_SUBJECT_PREFIX, job.data.name) - __default_body = "%s%s%s" % (cuegui.Constants.EMAIL_BODY_PREFIX, job.data.name, + + job_names = list(map(lambda job: job.data.name, jobs)) + if len(job_names) > SUBJ_LINE_TOO_LONG: + __default_subject = "%s%s" % (cuegui.Constants.EMAIL_SUBJECT_PREFIX, + ','.join(job_names[:2]) + '...') + else: + __default_subject = "%s%s" % (cuegui.Constants.EMAIL_SUBJECT_PREFIX, + ','.join(job_names)) + + __default_body = "%s%s%s" % (cuegui.Constants.EMAIL_BODY_PREFIX, + ',\n'.join(job_names), cuegui.Constants.EMAIL_BODY_SUFFIX) - __default_body += "Hi %s,\n\n" % user_name + __default_body += "Hi %s,\n\n" % user_names self.__btnSend = QtWidgets.QPushButton("Send", self) self.__btnCancel = QtWidgets.QPushButton("Cancel", self) diff --git a/cuegui/cuegui/MenuActions.py b/cuegui/cuegui/MenuActions.py index 2cd284f6d..8b0481668 100644 --- a/cuegui/cuegui/MenuActions.py +++ b/cuegui/cuegui/MenuActions.py @@ -234,7 +234,7 @@ def viewDepends(self, rpcObjects=None): def emailArtist(self, rpcObjects=None): jobs = self._getOnlyJobObjects(rpcObjects) if jobs: - cuegui.EmailDialog.EmailDialog(jobs[0], self._caller).show() + cuegui.EmailDialog.EmailDialog(jobs, self._caller).show() setMinCores_info = ["Set Minimum Cores...", "Set Job(s) Minimum Cores", "configure"] @@ -1444,7 +1444,7 @@ def __init__(self, *args): def viewComments(self, rpcObjects=None): hosts = self._getOnlyHostObjects(rpcObjects) if hosts: - cuegui.Comments.CommentListDialog(hosts[0], self._caller).show() + cuegui.Comments.CommentListDialog(hosts, self._caller).show() viewProc_info = ["View Procs", None, "log"] diff --git a/cuegui/tests/MenuActions_tests.py b/cuegui/tests/MenuActions_tests.py index b15a2cc9a..81749f67f 100644 --- a/cuegui/tests/MenuActions_tests.py +++ b/cuegui/tests/MenuActions_tests.py @@ -97,7 +97,7 @@ def test_emailArtist(self, emailDialogMock): self.job_actions.emailArtist(rpcObjects=[job]) - emailDialogMock.assert_called_with(job, self.widgetMock) + emailDialogMock.assert_called_with([job], self.widgetMock) emailDialogMock.return_value.show.assert_called() @mock.patch('PySide2.QtWidgets.QInputDialog.getDouble') @@ -1185,7 +1185,7 @@ def test_viewComments(self, commentListDialogMock): self.host_actions.viewComments(rpcObjects=[opencue.wrappers.layer.Layer, host]) - commentListDialogMock.assert_called_with(host, mock.ANY) + commentListDialogMock.assert_called_with([host], mock.ANY) commentListDialogMock.return_value.show.assert_called() @mock.patch('PySide2.QtGui.qApp')