Source code for activity_stream.activity_stream
# Copyright (c) 2015 Shotgun Software Inc.
#
# CONFIDENTIAL AND PROPRIETARY
#
# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit
# Source Code License included in this distribution package. See LICENSE.
# By accessing, using, copying or modifying this work you indicate your
# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights
# not expressly granted therein are reserved by Shotgun Software Inc.
import os
import sys
import sgtk
from sgtk.platform.qt import QtCore, QtGui
from .ui.activity_stream_widget import Ui_ActivityStreamWidget
from .widget_new_item import NewItemWidget, SimpleNewItemWidget
from .widget_note import NoteWidget
from .widget_value_update import ValueUpdateWidget
from .dialog_reply import ReplyDialog
from .data_manager import ActivityStreamDataHandler
from .overlaywidget import SmallOverlayWidget
note_input_widget = sgtk.platform.current_bundle().import_module("note_input_widget")
shotgun_globals = sgtk.platform.import_framework(
"tk-framework-shotgunutils", "shotgun_globals"
)
[docs]class ActivityStreamWidget(QtGui.QWidget):
"""
QT Widget that displays the Shotgun activity stream for an entity.
:signal entity_requested(str, int): Fires when someone clicks an entity inside
the activity stream. The returned parameters are entity type and entity id.
:signal playback_requested(dict): Fires when someone clicks the playback url
on a version. Returns a shotgun dictionary with information
about the version.
:signal entity_created(object): Fires when a Note or Reply entity is created by
an underlying widget within the activity stream. Returns a Shotgun dictionary
with information about the new Entity.
:ivar reply_dialog: When a ReplyDialog is active it can be accessed here. If there
is no ReplyDialog active, then this will be set to None.
:vartype reply_dialog: .dialog_reply.ReplyDialog or None
"""
# max number of items to show in the activity stream.
MAX_STREAM_LENGTH = 20
# Activity attributes that we do not want displayed.
_SKIP_ACTIVITY_ATTRIBUTES = ["viewed_by_current_user"]
entity_requested = QtCore.Signal(str, int)
playback_requested = QtCore.Signal(dict)
# The int is the Note entity id that was selected or deselected.
note_selected = QtCore.Signal(int)
note_deselected = QtCore.Signal(int)
note_arrived = QtCore.Signal(int)
# Emitted when a Note or Reply entity is created. The
# entity type as a string and id as an int will be
# provided.
#
# dict(entity_type="Note", id=1234)
entity_created = QtCore.Signal(object)
def __init__(self, parent):
"""
:param parent: QT parent object
:type parent: :class:`~PySide.QtGui.QWidget`
"""
# first, call the base class and let it do its thing.
QtGui.QWidget.__init__(self, parent)
self._bundle = sgtk.platform.current_bundle()
# now load in the UI that was created in the UI designer
self.ui = Ui_ActivityStreamWidget()
self.ui.setupUi(self)
# The note widget will be turned on when an entity is loaded
# if the entity is of an appropriate type.
self.ui.note_widget.hide()
# customizations
self._allow_screenshots = True
self._show_sg_stream_button = True
self._version_items_playable = True
self._clickable_user_icons = True
self._show_note_links = True
self._highlight_new_arrivals = True
self._notes_are_selectable = False
self._attachments_filter = None
# apply styling
self._load_stylesheet()
# keep an overlay for loading
overlay_module = self._bundle.import_module("overlay_widget")
self.__overlay = overlay_module.ShotgunOverlayWidget(self)
self.__small_overlay = SmallOverlayWidget(self)
# set insertion order into list to be bottom-up
self.ui.activity_stream_layout.setDirection(QtGui.QBoxLayout.BottomToTop)
# create a data manager to handle backend
self._data_manager = ActivityStreamDataHandler(self)
# set up signals
self._data_manager.note_arrived.connect(self._process_new_note)
self._data_manager.update_arrived.connect(self._process_new_data)
self._data_manager.thumbnail_arrived.connect(self._process_thumbnail)
self._data_manager.requesting_ui_refresh.connect(self._clear)
self.ui.note_widget.entity_created.connect(self._on_entity_created)
self.ui.note_widget.data_updated.connect(self.rescan)
# keep handles to all widgets to be nice to the GC
self._loading_widget = None
self._activity_stream_static_widgets = []
self._activity_stream_data_widgets = {}
# state management
self._task_manager = None
self._sg_entity_dict = None
self._entity_type = None
self._entity_id = None
self._select_on_arrival = dict()
# We'll be keeping a persistent reply dialog available because
# we need to connect to a signal that it's emitting. It's easiest
# to do that if we're dealing with an object that persists.
self.reply_dialog = ReplyDialog(
self,
self._task_manager,
note_id=None,
allow_screenshots=self._allow_screenshots,
)
# We'll allow for a pre-note-creation callback. This is for additional
# pre-processing that needs to occur before a Note or Reply is created
# in Shotgun. This makes sure that the activity stream data coming down
# during the rescan after submission contains anything like additional
# attachments that this widget didn't explicitly handle itself prior to
# submission.
self._pre_submit_callback = None
self.reply_dialog.note_widget.entity_created.connect(self._on_entity_created)
[docs] def set_bg_task_manager(self, task_manager):
"""
Specify the background task manager to use to pull
data in the background. Data calls
to Shotgun will be dispatched via this object.
:param task_manager: Background task manager to use
:type task_manager: :class:`~tk-framework-shotgunutils:task_manager.BackgroundTaskManager`
"""
self._task_manager = task_manager
shotgun_globals.register_bg_task_manager(task_manager)
self._data_manager.set_bg_task_manager(task_manager)
self.ui.note_widget.set_bg_task_manager(task_manager)
self.reply_dialog.set_bg_task_manager(task_manager)
[docs] def destroy(self):
"""
Should be called before the widget is closed
"""
self._data_manager.destroy()
self._task_manager = None
############################################################################
# properties
@property
def note_threads(self):
"""
The currently loaded note threads, keyed by Note entity id and
containing a list of Shotgun entity dictionaries. All note threads
currently displayed by the activity stream widget will be returned.
Example structure containing a Note, a Reply, and an attachment::
6040: [
{
'addressings_cc': [],
'addressings_to': [],
'client_note': False,
'content': 'This is a test note.',
'created_at': 1466477744.0,
'created_by': {
'id': 39,
'name': 'Jeff Beeland',
'type': 'HumanUser'
},
'id': 6040,
'note_links': [
{
'id': 1167,
'name': '123',
'type': 'Shot'
},
{
'id': 6023,
'name': 'Scene_v030_123',
'type': 'Version'
}
],
'read_by_current_user': 'read',
'subject': "Jeff's Note on Scene_v030_123, 123",
'tasks': [
{
'id': 2118,
'name': 'Comp',
'type': 'Task'
}
],
'type': 'Note',
'user': {
'id': 39,
'name': 'Jeff Beeland',
'type': 'HumanUser'
},
'user.ApiUser.image': None,
'user.ClientUser.image': None,
'user.HumanUser.image': 'https://url_to_file'
},
{
'content': 'test reply',
'created_at': 1469221928.0,
'id': 23,
'type': 'Reply',
'user': {
'id': 39,
'image': 'https://url_to_file',
'name': 'Jeff Beeland',
'type': 'HumanUser'
}
},
{
'attachment_links': [
{
'id': 6051,
'name': "Jeff's Note on Scene_v030_123, 123 - testing.",
'type': 'Note'
}
],
'created_at': 1469484693.0,
'created_by': {
'id': 39,
'name': 'Jeff Beeland',
'type': 'HumanUser'
},
'id': 601,
'image': 'https://url_to_file',
'this_file': {
'content_type': 'image/png',
'id': 601,
'link_type': 'upload',
'name': 'screencapture_vrviim.png',
'type': 'Attachment',
'url': 'https://url_to_file'
},
'type': 'Attachment'
},
]
"""
return self._data_manager.note_threads
@property
def note_widget(self):
"""
Returns the :class:`~note_input_widget.NoteInputWidget` contained within
the ActivityStreamWidget. Note that this is the widget used for NEW note
input and not Note replies. To get the NoteInputWidget used for Note
replies, access can be found via :meth:`ReplyDialog.note_widget`.
"""
return self.ui.note_widget
def _get_clickable_user_icons(self):
"""
Whether user icons in the activity stream display as clickable.
If True, a pointing hand cursor will be shown when the mouse is
hovered over the icons, otherwise the default arrow cursor will be
used.
"""
return self._clickable_user_icons
def _set_clickable_user_icons(self, state):
self._clickable_user_icons = bool(state)
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget):
if state:
widget.set_user_thumb_cursor(QtCore.Qt.PointingHandCursor)
else:
widget.set_user_thumb_cursor(QtCore.Qt.ArrowCursor)
clickable_user_icons = property(
_get_clickable_user_icons, _set_clickable_user_icons
)
def _get_pre_submit_callback(self):
"""
The pre-submit callback. This is None if one is not set, or a Python
callable if it is. This callable is run prior to submission of a new
Note or Reply. Note that the first (and only) argument passed to the
callback will be the calling :class:`NoteInputWidget`.
:returns: Python callable or None
"""
return self._pre_submit_callback
def _set_pre_submit_callback(self, callback):
self._pre_submit_callback = callback
self.reply_dialog.note_widget.pre_submit_callback = callback
self.note_widget.pre_submit_callback = callback
pre_submit_callback = property(_get_pre_submit_callback, _set_pre_submit_callback)
def _get_allow_screenshots(self):
"""
Whether this activity stream is allowed to give the user access to a
button that performs screenshot operations.
"""
return self._allow_screenshots
def _set_allow_screenshots(self, state):
self._allow_screenshots = bool(state)
self.ui.note_widget.allow_screenshots(self._allow_screenshots)
allow_screenshots = property(_get_allow_screenshots, _set_allow_screenshots)
def _get_show_sg_stream_button(self):
"""
Whether the button to navigate to Shotgun is shown in the stream.
"""
return self._show_sg_stream_button
def _set_show_sg_stream_button(self, state):
"""
Sets whether to show the button to navigate to Shotgun.
:param state: True or False
"""
self._show_sg_stream_button = bool(state)
show_sg_stream_button = property(
_get_show_sg_stream_button, _set_show_sg_stream_button
)
def _get_version_items_playable(self):
"""
Whether the label representing a created Version entity is shown
as being "playable" within the UI. If True, then a play icon is
visible over the thumbnail image, and no icon overlay is shown
when False.
"""
return self._version_items_playable
def _set_version_items_playable(self, state):
self._version_items_playable = bool(state)
version_items_playable = property(
_get_version_items_playable, _set_version_items_playable
)
def _get_show_note_links(self):
"""
If True, lists out the parent entity as a list of clickable
items for each Note entity that is represented in the activity
stream.
"""
return self._show_note_links
def _set_show_note_links(self, state):
self._show_note_links = bool(state)
show_note_links = property(_get_show_note_links, _set_show_note_links)
def _get_highlight_new_arrivals(self):
"""
If True, highlights items in the activity stream that are new
since the last time data was loaded.
"""
return self._highlight_new_arrivals
def _set_highlight_new_arrivals(self, state):
self._highlight_new_arrivals = bool(state)
highlight_new_arrivals = property(
_get_highlight_new_arrivals, _set_highlight_new_arrivals
)
def _get_notes_are_selectable(self):
return self._notes_are_selectable
def _set_notes_are_selectable(self, state):
self._notes_are_selectable = bool(state)
notes_are_selectable = property(
_get_notes_are_selectable, _set_notes_are_selectable
)
def _get_attachments_filter(self):
"""
If set to a compiled regular expression, attachment file names that match
will be filtered OUT and NOT shown.
.. note:: An re.match() is used, which means the regular expression must
match from the start of the attachment file's basename. See Python's
"re" module documentation for Python 2.x for more information and
examples.
Example to match only ".gif" extensions::
re.compile(r"\\w+[.]gif$")
"""
return self._attachments_filter
def _set_attachments_filter(self, regex):
self._attachments_filter = regex
attachments_filter = property(_get_attachments_filter, _set_attachments_filter)
############################################################################
# public interface
def select_note(self, note_id):
selectedWidget = None
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget):
match = widget.note_id == note_id
if match and not widget.selected:
self._note_selected_changed(True, widget.note_id)
selectedWidget = widget
widget.set_selected(match)
if selectedWidget is not None:
self.ui.activity_stream_scroll_area.ensureWidgetVisible(selectedWidget)
[docs] def deselect_note(self):
"""
If a note is currently selected, it will be deselected. This will NOT
trigger a note_deselected signal to be emitted, as that is only emitted
when the user triggers the deselection and not via procedural means.
"""
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget) and widget.selected:
widget.set_selected(False)
# We only support single selection right now, so we don't
# need to continue on once we've hit a note that's selected.
return
[docs] def get_note_attachments(self, note_id):
"""
Gets the Attachment entities associated with the given Note
entity. Only attachments from Notes currently loaded by the
activity stream widget will be returned.
.. note:: It is possible for attachments to be added to a Note
entity after the activity stream data has been cached.
In this situation, those attachments will NOT be returned,
as Shotgun will not be requeried for that new data unless
specifically requested to do so.
:param int note_id: The Note entity id.
"""
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget) and widget.note_id == note_id:
return widget.attachments
[docs] def load_data(self, sg_entity_dict):
"""
Reset the state of the widget and then load up the data
for a given entity.
:param dict sg_entity_dict: Dictionary with keys type and id
"""
self._bundle.log_debug(
"Setting up activity stream for entity %s" % sg_entity_dict
)
# clean up everything first
self._clear()
# change the state
self._sg_entity_dict = sg_entity_dict
self._entity_type = self._sg_entity_dict["type"]
self._entity_id = self._sg_entity_dict["id"]
# tell our "new note" widget which entity it should link up against
self.ui.note_widget.set_current_entity(self._entity_type, self._entity_id)
# to mimic the behavior in shotgun - which seems quite strange and
# inconsistent for users, we need to disable to note dialog for
# these cases
# note - this may return [] if shotgun globals aren't yet cached
schema_fields = shotgun_globals.get_entity_fields(self._entity_type)
is_non_project_entity_type = (
len(schema_fields) > 0 and "project" not in schema_fields
)
# if the project context is None, the entity is a non project entity and it's NOT
# the project entity itself, we don't have access to any project state.
no_project_available = (
self._bundle.context.project is None
and is_non_project_entity_type
and self._entity_type != "Project"
)
# also disable note creation in the case we have a site context
# and a non-project entity
if self._entity_type in ["ApiUser", "HumanUser", "ClientUser"]:
self.ui.note_widget.setVisible(False)
elif no_project_available:
# we don't have any project to hang these notes off, so disable
# the note integration
self.ui.note_widget.setVisible(False)
else:
self.ui.note_widget.setVisible(True)
# now load cached data for the given entity
self._bundle.log_debug("Setting up db manager....")
ids_to_process = self._data_manager.load_activity_data(
self._entity_type, self._entity_id, self.MAX_STREAM_LENGTH
)
if len(ids_to_process) == 0:
# nothing cached - show spinner!
# NOTE!!!! - cannot use the actual spinning animation because
# this triggers the GIL bug where signals from threads
# will deadlock the GIL
self.__overlay.show_message("Loading ShotGrid Data...")
all_reply_users = []
attachment_requests = []
###############################################################
# Phase 1 - render the UI.
# before we begin widget operations, turn off visibility
# of the whole widget in order to avoid recomputes
self._bundle.log_debug("Start building widgets based on cached data...")
self.setVisible(False)
try:
# we are building the widgets bottom up.
# first of all, insert a widget that will expand so that
# it consumes all unused space. This is to keep other
# widgets from growing when there are only a few widgets
# available in the scroll area.
self._bundle.log_debug("Adding expanding base widget...")
expanding_widget = QtGui.QLabel(self)
self.ui.activity_stream_layout.addWidget(expanding_widget)
self.ui.activity_stream_layout.setStretchFactor(expanding_widget, 1)
self._activity_stream_static_widgets.append(expanding_widget)
if self.show_sg_stream_button:
sg_stream_button = QtGui.QPushButton(self)
sg_stream_button.setText(
"Click here to see the Activity stream in ShotGrid."
)
sg_stream_button.setObjectName("full_shotgun_stream_button")
sg_stream_button.setCursor(QtCore.Qt.PointingHandCursor)
sg_stream_button.setFocusPolicy(QtCore.Qt.NoFocus)
sg_stream_button.clicked.connect(self._load_shotgun_activity_stream)
self.ui.activity_stream_layout.addWidget(sg_stream_button)
self._activity_stream_static_widgets.append(sg_stream_button)
# ids are returned in async order. Now pop them onto the activity stream,
# old items first order...
self._bundle.log_debug("Adding activity widgets...")
for activity_id in ids_to_process:
w = self._create_activity_widget(activity_id)
# note that not all activity data entries generate
# a widget in our factory method.
if w:
# a widget was generated! Insert it into
# the widget layouts etc.
self._activity_stream_data_widgets[activity_id] = w
self.ui.activity_stream_layout.addWidget(w)
# run extra init for notes
# this is to fetch the actual note payload -
# content, replies, attachments etc.
if isinstance(w, NoteWidget):
data = self._data_manager.get_activity_data(activity_id)
note_id = data["primary_entity"]["id"]
(
note_reply_users,
note_attachment_requests,
) = self._populate_note_widget(w, activity_id, note_id)
# extend user and attachment requests to our full list
# so that we can request thumbnails for these later...
all_reply_users.extend(note_reply_users)
attachment_requests.extend(note_attachment_requests)
# last, create "loading" widget
# to put at the top of the list.
#
# We add this into the scroll area so that it scrolls with the
# rest of the items in the list.
#
self._loading_widget = QtGui.QLabel(self)
self._loading_widget.setAlignment(
QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop
)
self._loading_widget.setText("Loading data from ShotGrid...")
self._loading_widget.setObjectName("loading_widget")
self.ui.activity_stream_layout.addWidget(self._loading_widget)
finally:
# make the window visible again and trigger a redraw
self.setVisible(True)
self._bundle.log_debug("...UI building complete!")
###############################################################
# Phase 2 - request additional data.
# note that we don't interleave these requests with building
# the ui - this is to minimise the risk of GIL signal issues
# request thumbs
self._bundle.log_debug("Request thumbnails...")
for activity_id in ids_to_process:
self._data_manager.request_activity_thumbnails(activity_id)
for attachment_req in attachment_requests:
self._data_manager.request_attachment_thumbnail(
attachment_req["activity_id"],
attachment_req["attachment_group_id"],
attachment_req["attachment_data"],
)
# now request thumbnails for all users who have replied, but
# only once per user
reply_users_dup_check = []
for reply_user in all_reply_users:
unique_user = (reply_user["type"], reply_user["id"])
if unique_user not in reply_users_dup_check:
reply_users_dup_check.append(unique_user)
self._data_manager.request_user_thumbnail(
reply_user["type"], reply_user["id"], reply_user["image"]
)
self._bundle.log_debug("...done")
# and now request an update check
self._bundle.log_debug("Ask db manager to ask shotgun for updates...")
self._data_manager.rescan()
self._bundle.log_debug("...done")
[docs] def show_new_note_dialog(self, modal=True):
"""
Shows a dialog that allows the user to input a new note.
.. note:: The return value of the new note dialog is not provided,
as the activity stream widget will emit an entity_created
signal if the user successfully creates a new Note entity.
:param bool modal: Whether the dialog should be shown modally or not.
"""
if self._entity_id == None:
self._bundle.log_debug("Skipping New Note Dialog - No entity loaded.")
return
note_dialog = note_input_widget.NoteInputDialog(parent=self)
note_dialog.entity_created.connect(self._on_entity_created)
note_dialog.data_updated.connect(self.rescan)
note_dialog.set_bg_task_manager(self._task_manager)
note_dialog.set_current_entity(self._entity_type, self._entity_id)
if modal:
note_dialog.exec_()
else:
note_dialog.show()
[docs] def rescan(self, force_activity_stream_update=False):
"""
Triggers a rescan of the current activity stream data.
:param force_activity_stream_update: If True, will force a requery
of activity stream data, even
if it is already cached.
:type force_activity_stream_update: bool
"""
# kick the data manager to rescan for changes
self._data_manager.rescan(
force_activity_stream_update=force_activity_stream_update
)
############################################################################
# internals
def _load_stylesheet(self):
"""
Loads in a stylesheet from disk
"""
qss_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style.qss")
try:
f = open(qss_file, "rt")
qss_data = f.read()
# apply to widget (and all its children)
self.setStyleSheet(qss_data)
finally:
f.close()
def _clear(self):
"""
Clear the widget. This will remove all items the UI
"""
self._bundle.log_debug("Clearing UI...")
# scroll to top
self.ui.activity_stream_scroll_area.verticalScrollBar().setValue(0)
try:
self._bundle.log_debug("Clear loading widget")
self._clear_loading_widget()
self._bundle.log_debug("Removing all widget items")
for x in self._activity_stream_data_widgets.values():
# remove widget from layout:
self.ui.activity_stream_layout.removeWidget(x)
# set it's parent to None so that it is removed from the widget hierarchy
x.setParent(None)
self._bundle.log_debug("Clearing python data structures")
self._activity_stream_data_widgets = {}
self._bundle.log_debug("Removing expanding widget")
for w in self._activity_stream_static_widgets:
self.ui.activity_stream_layout.removeWidget(w)
w.setParent(None)
self._activity_stream_static_widgets = []
finally:
# make the window visible again and trigger a redraw
# Since we have no entity loaded, we don't need to show
# the note widget.
self.ui.note_widget.setVisible(False)
self.ui.note_widget.clear()
def _clear_loading_widget(self):
"""
Remove the loading widget from the widget list
"""
if self._loading_widget:
self._bundle.log_debug("Clearing the loading widget")
self.ui.activity_stream_layout.removeWidget(self._loading_widget)
self._loading_widget.setParent(None)
self._loading_widget = None
self._bundle.log_debug("...done")
def _populate_note_widget(self, note_widget, activity_id, note_id):
"""
Load note content and replies into a note widget
:param note_widget: Note widget to populate with replies and
attachments.
:param activity_id: Activity stream id to load
:param note_id: Note id to load
:returns: (reply_users, attachment_requests) where reply_users is a
list of users (dict with type, id, name and image) for each
of the replies and attachment_requests a list of dicts of
attahchment request dictionaries
"""
# set note content
note_thread_data = self._data_manager.get_note(note_id)
attachment_requests = []
reply_users = []
if note_thread_data:
# we have cached note data
note_data = note_thread_data[0]
replies_and_attachments = note_thread_data[1:]
# set up the note data first
note_widget.set_note_info(note_data)
# now add replies
note_widget.add_replies(replies_and_attachments)
# add a reply button and connect it
reply_button = note_widget.add_reply_button()
reply_button.clicked.connect(lambda: self._on_reply_clicked(note_id))
# get list of users who have replied
for item in replies_and_attachments:
if item["type"] == "Reply":
# note that the reply data structure is special:
# the 'user' key is not a normal sg link dict,
# but contains an additional image field to describe
# the thumbnail:
#
# {'content': 'Reply content...',
# 'created_at': 1438649419.0,
# 'type': 'Reply',
# 'id': 73,
# 'user': {'image': '...',
# 'type': 'HumanUser',
# 'id': 38,
# 'name': 'Manne Ohrstrom'}}]
reply_users.append(item["user"])
# get all attachment data
# can request thumbnails post UI build
for attachment_group_id in note_widget.get_attachment_group_widget_ids():
agw = note_widget.get_attachment_group_widget(attachment_group_id)
for attachment_data in agw.get_data():
ag_request = {
"attachment_group_id": attachment_group_id,
"activity_id": activity_id,
"attachment_data": attachment_data,
}
attachment_requests.append(ag_request)
return (reply_users, attachment_requests)
def _create_activity_widget(self, activity_id):
"""
Create a widget for a given activity id
If the activity id is not supported by the implementation,
returns None. This can for example happen if the type of
data returned by the activity stream doesn't have a
suitable widget implemented.
:returns: Activity widget object or None
"""
data = self._data_manager.get_activity_data(activity_id)
widget = None
# factory logic
if data["update_type"] == "create":
if data["primary_entity"]["type"] in [
"Version",
"PublishedFile",
"TankPublishedFile",
]:
# full on 'new item' widget with thumbnail, description etc.
widget = NewItemWidget(self)
widget.interactive = self.version_items_playable
elif data["primary_entity"]["type"] == "Note":
# Ensure that the user has permission to see script user
# (if not, data["primary_entity"]["user"] will be None)
if data["primary_entity"]["user"]:
# new note
widget = NoteWidget(data["primary_entity"]["id"], self)
widget.show_note_links = self.show_note_links
widget.attachments_filter = self.attachments_filter
# If it's been requested that we select this Note entity's widget when it's
# constructed, then we do so here. This is the situation where the user has
# created a new Note, which upon completion we want to have autoselected.
if data["primary_entity"]["id"] == self._select_on_arrival.get(
"id"
):
# First we need to deselect whatever is selected, because we're going
# to be selecting our new widget right after.
for w in self._activity_stream_data_widgets.values():
if isinstance(w, NoteWidget):
if w.selected:
w.set_selected(False)
self._note_selected_changed(False, w.note_id)
widget.set_selected(True)
self._note_selected_changed(True, widget.note_id)
self._select_on_arrival = dict()
else:
# minimalistic 'new' widget for all other cases
widget = SimpleNewItemWidget(self)
elif data["update_type"] == "create_reply":
# new note widget
widget = NoteWidget(data["primary_entity"]["id"], self)
widget.show_note_links = self.show_note_links
widget.attachments_filter = self.attachments_filter
elif data["update_type"] == "update":
# 37660: We're going to ignore "viewed by" activity for the time being.
# According to the review team these entries shouldn't have been returned
# as part of the stream anyway, but we have existing data that might
# contain these entries that we need to handle elegantly.
if (
data.get("meta", {}).get("attribute_name")
not in self._SKIP_ACTIVITY_ATTRIBUTES
):
widget = ValueUpdateWidget(self)
else:
self._bundle.log_debug(
"Activity type not supported and will not be "
"rendered: %s" % data["update_type"]
)
# initialize the widget
if widget:
widget.set_host_entity(self._entity_type, self._entity_id)
widget.set_info(data)
widget.entity_requested.connect(
lambda entity_type, entity_id: self.entity_requested.emit(
entity_type, entity_id
)
)
widget.playback_requested.connect(
lambda sg_data: self.playback_requested.emit(sg_data)
)
# If we're not wanting the user icons to display as clickable, then
# we need to set their cursor to be the default arrow cursor. Otherwise
# we don't need to do anything because they default to the clickable
# finger-pointing cursor.
if not self.clickable_user_icons and isinstance(widget, NoteWidget):
widget.set_user_thumb_cursor(QtCore.Qt.ArrowCursor)
return widget
def _note_selected_changed(self, selected, note_id):
"""
Handles a change in selection state for a given Note entity id.
:param bool selected: The new selection state of the Note.
:param int note_id: The Note entity id.
"""
if selected:
self.note_selected.emit(note_id)
else:
self.note_deselected.emit(note_id)
def _process_new_data(self, activity_ids):
"""
Process new activity ids as they arrive from
the data manager.
:param activity_ids: List of activity ids
"""
self._bundle.log_debug(
"Process new data called for %s activity events" % len(activity_ids)
)
# keep track of new note widgets created
note_widgets_added = []
# remove the "loading please wait .... widget
self._clear_loading_widget()
# note! For an item which hasn't been previously been cached or
# hasn't been visited for some time, there may be a lot more than
# MAX_STREAM_LENGTH updates. In this case, truncate the stream.
# this will result in a UI where you may have a maxmimum of
# MAX_STREAM_LENGTH * 2 items (already loaded + new) and there
# may be gaps in activity data because we always want to show
# the latest data, so when we cull, it happens in the 'middle'
# of the stream, resulting in existing data, the a potential gap
# and then MAX_STREAM_LENGTH items.
#
# Note that this is in the UI only, so a refresh of the page
# would immediately rectify the discrepancy.
# load in the new data
# the list of ids is delivered in ascending order
# and we pop them on to the widget
if len(activity_ids) > self.MAX_STREAM_LENGTH:
self._bundle.log_debug(
"Capping the %s new activity items down to "
"%s items" % (len(activity_ids), self.MAX_STREAM_LENGTH)
)
# transform [10,11,12,13,14,15,16,17] -> [15,16,17]
activity_ids = activity_ids[-self.MAX_STREAM_LENGTH :]
for activity_id in activity_ids:
self._bundle.log_debug("Creating new widget...")
w = self._create_activity_widget(activity_id)
if w:
self._activity_stream_data_widgets[activity_id] = w
self._bundle.log_debug("Adding %s to layout" % w)
self.ui.activity_stream_layout.addWidget(w)
# add special blue border to indicate that this is a new arrival
if self.highlight_new_arrivals:
w.setStyleSheet(
"QFrame#frame{ border: 1px solid rgba(48, 167, 227, 50%); }"
)
# register if it is a note so we can post process
if isinstance(w, NoteWidget):
note_widgets_added.append(w)
# when everything is loaded in, load the thumbs
self._bundle.log_debug("Requesting thumbnails")
for activity_id in activity_ids:
self._data_manager.request_activity_thumbnails(activity_id)
self._bundle.log_debug("Process new data complete.")
# now cull out any activity items that are duplicated in the list
# this may be the case if a note has been replied to - in this case
# the note already exists in the list
note_ids_added = [widget.note_id for widget in note_widgets_added]
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget) and widget not in note_widgets_added:
if widget.note_id in note_ids_added:
widget.hide()
# turn off the overlay in case it is spinning
# (which only happens on a full load)
self.__overlay.hide()
def _process_thumbnail(self, data):
"""
New thumbnail has arrived from the data manager
"""
# broadcast to all activity widgets
for widget in self._activity_stream_data_widgets.values():
widget.apply_thumbnail(data)
def _process_new_note(self, activity_id, note_id):
"""
A new note has arrived from the data manager
"""
if activity_id in self._activity_stream_data_widgets:
widget = self._activity_stream_data_widgets[activity_id]
(reply_users, attachment_requests) = self._populate_note_widget(
widget, activity_id, note_id
)
# request thumbs
for attachment_req in attachment_requests:
self._data_manager.request_attachment_thumbnail(
attachment_req["activity_id"],
attachment_req["attachment_group_id"],
attachment_req["attachment_data"],
)
for reply_user in reply_users:
self._data_manager.request_user_thumbnail(
reply_user["type"], reply_user["id"], reply_user["image"]
)
self.note_arrived.emit(note_id)
else:
self.note_arrived.emit(note_id)
def _on_entity_created(self, entity):
"""
Callback when an entity is created by an underlying widget.
:param entity: The Shotgun entity that was created.
"""
if entity["type"] == "Note":
try:
from sgtk.util.metrics import EventMetric
fields = [] # reserved for future use
annotations = {} # reserved for future use
properties = {
"Source": "Activity Stream",
"Linked Entity Type": entity.get("type", "Unknown"),
"Field Used": fields,
"Annotations": annotations,
}
EventMetric.log(
EventMetric.GROUP_MEDIA,
"Created Note",
properties=properties,
bundle=self._bundle,
)
except:
# ignore all errors. ex: using a core that doesn't support metrics
pass
if self.notes_are_selectable:
self._select_on_arrival = entity
self.entity_created.emit(entity)
def _on_reply_clicked(self, note_id):
"""
Callback when someone clicks reply on a given note
:param note_id: The id of the Shotgun Note entity being replied to.
"""
self.reply_dialog.note_id = note_id
# Position the reply modal dialog above the activity stream scroll area.
pos = self.mapToGlobal(self.ui.activity_stream_scroll_area.pos())
x_pos = (
pos.x()
+ (self.ui.activity_stream_scroll_area.width() / 2)
- (self.reply_dialog.width() / 2)
- 10
)
y_pos = (
pos.y()
+ (self.ui.activity_stream_scroll_area.height() / 2)
- (self.reply_dialog.height() / 2)
- 20
)
self.reply_dialog.move(x_pos, y_pos)
# and pop it
try:
self.__small_overlay.show()
if self.reply_dialog.exec_() == QtGui.QDialog.Accepted:
self.load_data(self._sg_entity_dict)
try:
from sgtk.util.metrics import EventMetric
properties = {"Source": "Activity Stream"}
EventMetric.log(
EventMetric.GROUP_MEDIA,
"Created Reply",
properties=properties,
bundle=self._bundle,
)
except:
# ignore all errors. ex: using a core that doesn't support metrics
pass
finally:
self.__small_overlay.hide()
def _load_shotgun_activity_stream(self):
"""
Called when someone clicks 'show activity stream in shotgun'
"""
url = "%s/detail/%s/%s" % (
self._bundle.sgtk.shotgun_url,
self._entity_type,
self._entity_id,
)
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
############################################################################
# events
[docs] def mousePressEvent(self, event):
"""
Overrides the default event handler in Qt.
"""
if not self.notes_are_selectable:
return
# If they clicked on a note, select it. Any notes that were not
# clicked on will be deselected.
position = event.globalPos()
for widget in self._activity_stream_data_widgets.values():
if isinstance(widget, NoteWidget):
selected = widget.underMouse()
if selected != widget.selected:
widget.set_selected(selected)
self._note_selected_changed(selected, widget.note_id)