-
Notifications
You must be signed in to change notification settings - Fork 24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] Add a feedback dialog box #93
Changes from 4 commits
b25c109
faf06fc
644b52a
9dc5122
8e9dcc8
f2fb60b
fc6e146
4057d6d
2afabb7
b94d489
b6e7b01
82bd9dc
40bcab8
a5471da
2a304a8
beb3813
7c407d7
40f47fd
b7da2c2
0db10aa
f60992c
1a61255
b34a768
ef1a643
1ee8f74
d7ffe07
e659e49
133a35f
12691b6
d651bf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
""" | ||
This module implements a class that provides logic for a simple plugin | ||
for sending messages to a developer team's slack channel. | ||
""" | ||
|
||
import io | ||
|
||
import numpy as np | ||
import slack | ||
import aiohttp | ||
from PIL import Image | ||
from traits.api import ( | ||
HasTraits, Str, Property, | ||
Int, Array, Bytes, String, | ||
cached_property, on_trait_change) | ||
|
||
|
||
class FeedbackMessage(HasTraits): | ||
"""Model for the feedback message. | ||
|
||
Notes | ||
----- | ||
The user-developer must specify the slack channel that the message must be | ||
sent to, as well as provide raw screenshot data. | ||
|
||
""" | ||
|
||
first_name = Str(msg_meta=True) | ||
|
||
last_name = Str(msg_meta=True) | ||
|
||
#: Name of the client organization. | ||
organization = Str(msg_meta=True) | ||
|
||
# TODO: Slack supports some markdown in messages, provide | ||
# some details here. | ||
#: Main body of the feedback message. | ||
description = Str(msg_meta=True) | ||
|
||
#: The target slack channel that the bot will post to, must start with #. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Channels" and "tokens" are not part of the concept of a "message". They are part of the concept of a (particular) delivery system and so should be factored out into their own class (see main review comment). |
||
channels = String(minlen=2, regex='#.*') | ||
|
||
#: OAuth token for the slackbot, must be provided by the user-developer. | ||
token = Str | ||
|
||
#: The final slack message that will be posted. | ||
msg = Str | ||
|
||
#: The screenshot pixel data in raw bytes. Note that RGB[A] ordering is assumed. | ||
img_bytes = Bytes | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a You are using Chaco and/or Enable here, so numpy is already a dependency. |
||
|
||
#: The screenshot width in pixels. | ||
img_w = Int | ||
|
||
#: The screenshot height in pixels. | ||
img_h = Int | ||
|
||
@on_trait_change('+msg_meta') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this it makes more sense to use a |
||
def _update_msg(self): | ||
|
||
feedback_template = 'Name: {first} {last}\n' \ | ||
+ 'Organization: {org}\nDescription: {desc}' | ||
|
||
self.msg = feedback_template.format( | ||
first=self.first_name, | ||
last=self.last_name, | ||
org=self.organization, | ||
desc=self.description) | ||
|
||
def send(self): | ||
""" Send feedback message and screenshot to slack. """ | ||
|
||
client = slack.WebClient(token=self.token, | ||
timeout=5, | ||
ssl=True) | ||
|
||
# Compress image into PNG format using an in-memory buffer. | ||
#img = Image.fromarray(self.img_data, mode='RGB') | ||
img = Image.frombytes('RGBA', (self.img_w, self.img_h), self.img_bytes) | ||
|
||
buf = io.BytesIO() | ||
|
||
img.save(buf, 'PNG') | ||
buf.seek(0) | ||
|
||
try: | ||
|
||
# Send message. | ||
response = client.files_upload( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I notice that Slack is using For UX reasons you want to keep the UI responsive while the files are uploading; it would be awesome if you can give progress; but you don't want to impose major architectural/technology constraints on the users of your library. This is deep water here. Be careful. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. Slack provides a flag which lets users decide whether they want to use the asynchronous feature or not. The flag is |
||
channels=self.channels, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: indentation. Please run flake8 (or equivalent) over your code. |
||
initial_comment=self.msg, | ||
filetype='png', | ||
filename='screenshot.png', | ||
file=buf) | ||
|
||
except slack.errors.SlackApiError as error: | ||
|
||
print( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use print, instead use
and then here you would use |
||
'Message sent successfully,' | ||
+ ' but received the following error from Slack:') | ||
print(error) | ||
raise | ||
|
||
except aiohttp.client_exceptions.ClientConnectorError as error: | ||
|
||
print('Message not sent.') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment here about logging. |
||
print(error) | ||
raise | ||
|
||
else: | ||
|
||
print('Message sent successfully!') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And here, but this would be |
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
""" | ||
This module provides some helper functions for the feedback plugin. These | ||
cfarrow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
functions are designed to be used by the user-developer so that the feedback | ||
plugin can be used in their application. | ||
""" | ||
|
||
from PyQt4.QtGui import QPixmap | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please import from |
||
import numpy as np | ||
|
||
def take_screenshot_qimg(info): | ||
""" Take screenshot of an active GUI widget. | ||
|
||
Parameters | ||
---------- | ||
info: traitsui.UIInfo | ||
The UIInfo instance which contains the active widget. | ||
|
||
Returns: | ||
-------- | ||
qimg: PyQt4.QtGui.QImage | ||
Screenshot image as PyQt4.QtGui.QImage instance. | ||
|
||
""" | ||
|
||
pixmap = QPixmap.grabWidget(info.ui.control) | ||
|
||
qimg = pixmap.toImage() | ||
|
||
return qimg | ||
|
||
def get_raw_qimg_data(qimg): | ||
""" Get raw image data (BGR[A] values, and size in pixels). | ||
|
||
Parameters: | ||
qimg: PyQt4.QtGui.Qimage instance | ||
|
||
Returns: | ||
-------- | ||
bytes | ||
Raw bytes ordered as BGR[A]. Alpha channel is included if available. | ||
int | ||
Image height in pixels. | ||
int | ||
Image width in pixels. | ||
|
||
""" | ||
|
||
qbits = qimg.bits() | ||
|
||
num_channels = qimg.depth() // 8 | ||
|
||
qbits.setsize(qimg.width() * qimg.height() * num_channels) | ||
|
||
return [qbits.asstring(), qimg.height(), qimg.width()] | ||
|
||
def bgr_bytes_to_rgb_bytes(bgr_bytes, height, width): | ||
""" Convert BGR[A] bytestring to RGB[A] bytestring. | ||
|
||
Note | ||
---- | ||
This function is designed to convert the BGR[A]-ordered | ||
bytestring of a PyQt4.QtGui.QImage into RGB[A] ordering. | ||
An alpha-channel is not necessary, but will be handled if provided. | ||
|
||
Parameters | ||
---------- | ||
bgr_bytes: bytes | ||
BGR[A]-ordered bytestring. | ||
|
||
height: int | ||
Height of image in pixels. | ||
|
||
width: int | ||
Height of image in pixels. | ||
|
||
Returns | ||
------- | ||
bytes | ||
RGB[A]-ordered bytes | ||
|
||
""" | ||
|
||
bgr_mat = bytes_to_matrix(bgr_bytes, height, width) | ||
|
||
num_channels = bgr_mat.shape[2] | ||
|
||
if num_channels == 3: | ||
|
||
new_channel_idx = [2, 1, 0] | ||
|
||
elif num_channels == 4: | ||
|
||
new_channel_idx = [2, 1, 0, 3] | ||
|
||
else: | ||
|
||
raise ValueError( | ||
"Image has {} channels. Expected 3 or 4.".format(num_channels)) | ||
|
||
return bgr_mat[..., new_channel_idx].tobytes() | ||
|
||
def bytes_to_matrix(bytes_str, height, width): | ||
|
||
return np.ascontiguousarray(np.frombuffer( | ||
bytes_str, dtype=np.uint8).reshape(height, width, -1)) | ||
|
||
def bytes_to_buffer(bytes_str, height, width, fmt): | ||
|
||
img = Image.frombytes('RGBA', (width, height), bytes_str) | ||
|
||
buf = io.BytesIO() | ||
|
||
img.save(buf, fmt) | ||
buf.seek(0) | ||
|
||
return buf | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
""" | ||
This model implements UI classes and logic for a plugin that enables | ||
clients to send feedback messages to a developer team's slack channel. | ||
""" | ||
|
||
from traits.api import Property, Instance | ||
from traitsui.api import ( | ||
View, Group, Item, Action, | ||
Label, Controller) | ||
from traitsui.menu import CancelButton | ||
from chaco.api import Plot, ArrayPlotData | ||
from enable.api import ComponentEditor | ||
|
||
from .model import FeedbackMessage | ||
from .utils import bytes_to_matrix | ||
|
||
# ---------------------------------------------------------------------------- | ||
# TraitsUI Actions | ||
# ---------------------------------------------------------------------------- | ||
|
||
send_button = Action(name='Send', action='send', | ||
enabled_when='controller._send_enabled') | ||
|
||
# ---------------------------------------------------------------------------- | ||
# TraitsUI Views | ||
# ---------------------------------------------------------------------------- | ||
|
||
#: Primary view for the feedback message. | ||
feedback_msg_view = View( | ||
Label('Enter feedback here. All fields are mandatory.'), | ||
Group( | ||
Group( | ||
Item('first_name'), | ||
Item('last_name'), | ||
Item('organization', | ||
tooltip='Enter the name of your organization.'), | ||
Item('description', | ||
tooltip='Enter feedback.', | ||
height=200, | ||
springy=True)), | ||
Group( | ||
Item('controller.screenshot_plot', | ||
editor=ComponentEditor(), | ||
show_label=False)), | ||
orientation='horizontal'), | ||
buttons=[CancelButton, send_button], | ||
width=800, | ||
resizable=True) | ||
|
||
|
||
# ---------------------------------------------------------------------------- | ||
# TraitsUI Handler | ||
# ---------------------------------------------------------------------------- | ||
|
||
class FeedbackController(Controller): | ||
cfarrow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Controller for FeedbackMessage. | ||
|
||
The Controller allows the client user to specify the feedback and preview | ||
the screenshot. | ||
|
||
""" | ||
|
||
model = Instance(FeedbackMessage) | ||
|
||
#: Chaco plot to display the screenshot. | ||
screenshot_plot = Instance(Plot) | ||
|
||
#: Property that decides whether the state of the message is valid | ||
# for sending. | ||
_send_enabled = Property(depends_on='[+msg_meta]') | ||
|
||
trait_view = feedback_msg_view | ||
|
||
def _screenshot_plot_default(self): | ||
""" Plots screenshot in Chaco from RGB data. """ | ||
|
||
# Reverse rows of model.img_data so that the img_plot looks right | ||
|
||
cfarrow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
img_data = bytes_to_matrix( | ||
self.model.img_bytes, self.model.img_h, self.model.img_w) | ||
|
||
plotdata = ArrayPlotData(img_data=img_data[::-1, ...]) | ||
plot = Plot(plotdata) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't use Chaco for this, instead use an Enable |
||
plot.img_plot('img_data', hide_grids=True) | ||
|
||
plot.border_visible = False | ||
plot.x_axis = None | ||
plot.y_axis = None | ||
|
||
return plot | ||
|
||
def _get__send_enabled(self): | ||
""" Logic to check if message is valid for sending. """ | ||
|
||
return self.model.first_name and self.model.last_name \ | ||
and self.model.organization and self.model.description | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please give a comment for all trait definitions, eg.
#: The first name of the user.
A secondary comment is that it's probably easier to have just one name field unless there is some compelling reason to split it.