Skip to content
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

Add more validation rules for view construction (thanks to feedback in #519) #689

Merged
merged 1 commit into from
May 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions integration_tests/samples/basic_usage/views_2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ------------------
# Only for running this script here
import json
import logging
import sys
from os.path import dirname

sys.path.insert(1, f"{dirname(__file__)}/../../..")
logging.basicConfig(level=logging.DEBUG)

# ---------------------
# Slack WebClient
# ---------------------

import os

from slack import WebClient
from slack.errors import SlackApiError
from slack.signature import SignatureVerifier
from slack.web.classes.blocks import InputBlock
from slack.web.classes.elements import PlainTextInputElement
from slack.web.classes.objects import PlainTextObject
from slack.web.classes.views import View

client = WebClient(token=os.environ["SLACK_API_TOKEN"])
signature_verifier = SignatureVerifier(os.environ["SLACK_SIGNING_SECRET"])

# ---------------------
# Flask App
# ---------------------

# pip3 install flask
from flask import Flask, request, make_response

app = Flask(__name__)


@app.route("/slack/events", methods=["POST"])
def slack_app():
if not signature_verifier.is_valid_request(request.get_data(), request.headers):
return make_response("invalid request", 403)

if "command" in request.form \
and request.form["command"] == "/open-modal":
trigger_id = request.form["trigger_id"]
try:
view = View(
type="modal",
callback_id="modal-id",
title=PlainTextObject(text="Awesome Modal"),
submit=PlainTextObject(text="Submit"),
close=PlainTextObject(text="Cancel"),
blocks=[
InputBlock(
block_id="b-id",
label=PlainTextObject(text="Input label"),
element=PlainTextInputElement(action_id="a-id")
)
]
)
response = client.views_open(
trigger_id=trigger_id,
view=view
)
return make_response("", 200)
except SlackApiError as e:
code = e.response["error"]
return make_response(f"Failed to open a modal due to {code}", 200)

elif "payload" in request.form:
payload = json.loads(request.form["payload"])
if payload["type"] == "view_submission" \
and payload["view"]["callback_id"] == "modal-id":
submitted_data = payload["view"]["state"]["values"]
print(submitted_data) # {'b-id': {'a-id': {'type': 'plain_text_input', 'value': 'your input'}}}
return make_response("", 200)

return make_response("", 404)


if __name__ == "__main__":
# export SLACK_SIGNING_SECRET=***
# export SLACK_API_TOKEN=xoxb-***
# export FLASK_ENV=development
# python3 integration_tests/samples/basic_usage/views_2.py
app.run("localhost", 3000)

# ngrok http 3000
23 changes: 21 additions & 2 deletions slack/web/classes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class View(JsonObject):
https://api.slack.com/reference/surfaces/views
"""

types = ["modal", "home"]

attributes = {
"type",
"id",
Expand All @@ -36,7 +38,7 @@ class View(JsonObject):

def __init__(
self,
type: str = None, # "modal", "home"
type: str, # "modal", "home"
id: Optional[str] = None,
callback_id: Optional[str] = None,
external_id: Optional[str] = None,
Expand Down Expand Up @@ -83,14 +85,31 @@ def __init__(
private_metadata_max_length = 3000
callback_id_max_length: int = 255

@JsonValidator('type must be either "modal" or "home"')
def _validate_type(self):
return self.type is not None and self.type in self.types

@JsonValidator(f"title must be between 1 and {title_max_length} characters")
def _validate_title_length(self):
return self.title is None or 1 <= len(self.title.text) <= self.title_max_length

@JsonValidator(f"modals must contain between 1 and {blocks_max_length} blocks")
@JsonValidator(f"views must contain between 1 and {blocks_max_length} blocks")
def _validate_blocks_length(self):
return self.blocks is None or 0 < len(self.blocks) <= self.blocks_max_length

@JsonValidator("home view cannot have input blocks")
def _validate_input_blocks(self):
return self.type == "modal" or (
self.type == "home"
and len([b for b in self.blocks if b.type == "input"]) == 0
)

@JsonValidator("home view cannot have submit and close")
def _validate_home_tab_structure(self):
return self.type != "home" or (
self.type == "home" and self.close is None and self.submit is None
)

@JsonValidator(f"close cannot exceed {close_max_length} characters")
def _validate_close_length(self):
return self.close is None or len(self.close.text) <= self.close_max_length
Expand Down
11 changes: 8 additions & 3 deletions slack/web/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import slack.errors as e
from slack.web.base_client import BaseClient, SlackResponse
from slack.web.classes.views import View


class WebClient(BaseClient):
Expand Down Expand Up @@ -1855,7 +1856,7 @@ def users_profile_set(self, **kwargs) -> Union[Future, SlackResponse]:
return self.api_call("users.profile.set", json=kwargs)

def views_open(
self, *, trigger_id: str, view: dict, **kwargs
self, *, trigger_id: str, view: Union[dict, View], **kwargs
) -> Union[Future, SlackResponse]:
"""Open a view for a user.

Expand All @@ -1868,9 +1869,13 @@ def views_open(
Args:
trigger_id (str): Exchange a trigger to post to the user.
e.g. '12345.98765.abcd2358fdea'
view (dict): The view payload.
view (dict or View): The view payload.
"""
kwargs.update({"trigger_id": trigger_id, "view": view})
kwargs.update({"trigger_id": trigger_id})
if isinstance(view, View):
kwargs.update({"view": view.to_dict()})
else:
kwargs.update({"view": view})
return self.api_call("views.open", json=kwargs)

def views_push(
Expand Down
168 changes: 167 additions & 1 deletion tests/web/classes/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import logging
import unittest

from slack.web.classes.objects import PlainTextObject, Option
from slack.errors import SlackObjectFormationError
from slack.web.classes.blocks import InputBlock, SectionBlock, DividerBlock, ActionsBlock, ContextBlock
from slack.web.classes.elements import PlainTextInputElement, RadioButtonsElement, CheckboxesElement, ButtonElement, \
ImageElement
from slack.web.classes.objects import PlainTextObject, Option, MarkdownTextObject
from slack.web.classes.views import View, ViewState, ViewStateValue


Expand All @@ -21,6 +25,85 @@ def verify_loaded_view_object(self, file):
# Modals
# --------------------------------

def test_valid_construction(self):
modal_view = View(
type="modal",
callback_id="modal-id",
title=PlainTextObject(text="Awesome Modal"),
submit=PlainTextObject(text="Submit"),
close=PlainTextObject(text="Cancel"),
blocks=[
InputBlock(
block_id="b-id",
label=PlainTextObject(text="Input label"),
element=PlainTextInputElement(action_id="a-id")
),
InputBlock(
block_id="cb-id",
label=PlainTextObject(text="Label"),
element=CheckboxesElement(
action_id="a-cb-id",
options=[
Option(text=PlainTextObject(text="*this is plain_text text*"), value="v1"),
Option(text=MarkdownTextObject(text="*this is mrkdwn text*"), value="v2"),
],
),
),
SectionBlock(
block_id="sb-id",
text=MarkdownTextObject(text="This is a mrkdwn text section block."),
fields=[
PlainTextObject(text="*this is plain_text text*", emoji=True),
MarkdownTextObject(text="*this is mrkdwn text*"),
PlainTextObject(text="*this is plain_text text*", emoji=True),
]
),
DividerBlock(),
SectionBlock(
block_id="rb-id",
text=MarkdownTextObject(text="This is a section block with radio button accessory"),
accessory=RadioButtonsElement(
initial_option=Option(
text=PlainTextObject(text="Option 1"),
value="option 1",
description=PlainTextObject(text="Description for option 1"),
),
options=[
Option(
text=PlainTextObject(text="Option 1"),
value="option 1",
description=PlainTextObject(text="Description for option 1"),
),
Option(
text=PlainTextObject(text="Option 2"),
value="option 2",
description=PlainTextObject(text="Description for option 2"),
),
]
)
)
]
)
modal_view.validate_json()

def test_invalid_type_value(self):
modal_view = View(
type="modallll",
callback_id="modal-id",
title=PlainTextObject(text="Awesome Modal"),
submit=PlainTextObject(text="Submit"),
close=PlainTextObject(text="Cancel"),
blocks=[
InputBlock(
block_id="b-id",
label=PlainTextObject(text="Input label"),
element=PlainTextInputElement(action_id="a-id")
),
]
)
with self.assertRaises(SlackObjectFormationError):
modal_view.validate_json()

def test_simple_state_values(self):
expected = {
"values": {
Expand Down Expand Up @@ -270,6 +353,89 @@ def test_load_modal_view_010(self):
# Home Tabs
# --------------------------------

def test_home_tab_construction(self):
home_tab_view = View(
type="home",
blocks=[
SectionBlock(
text=MarkdownTextObject(text="*Here's what you can do with Project Tracker:*"),
),
ActionsBlock(
elements=[
ButtonElement(
text=PlainTextObject(text="Create New Task", emoji=True),
style="primary",
value="create_task",
),
ButtonElement(
text=PlainTextObject(text="Create New Project", emoji=True),
value="create_project",
),
ButtonElement(
text=PlainTextObject(text="Help", emoji=True),
value="help",
),
],
),
ContextBlock(
elements=[
ImageElement(
image_url="https://api.slack.com/img/blocks/bkb_template_images/placeholder.png",
alt_text="placeholder",
),
],
),
SectionBlock(
text=MarkdownTextObject(text="*Your Configurations*"),
),
DividerBlock(),
SectionBlock(
text=MarkdownTextObject(
text="*#public-relations*\n<fakelink.toUrl.com|PR Strategy 2019> posts new tasks, comments, and project updates to <fakelink.toChannel.com|#public-relations>"),
accessory=ButtonElement(
text=PlainTextObject(text="Edit", emoji=True),
value="public-relations",
),
)
],
)
home_tab_view.validate_json()

def test_input_blocks_in_home_tab(self):
modal_view = View(
type="home",
callback_id="home-tab-id",
blocks=[
InputBlock(
block_id="b-id",
label=PlainTextObject(text="Input label"),
element=PlainTextInputElement(action_id="a-id")
),
]
)
with self.assertRaises(SlackObjectFormationError):
modal_view.validate_json()

def test_submit_in_home_tab(self):
modal_view = View(
type="home",
callback_id="home-tab-id",
submit=PlainTextObject(text="Submit"),
blocks=[DividerBlock()]
)
with self.assertRaises(SlackObjectFormationError):
modal_view.validate_json()

def test_close_in_home_tab(self):
modal_view = View(
type="home",
callback_id="home-tab-id",
close=PlainTextObject(text="Cancel"),
blocks=[DividerBlock()]
)
with self.assertRaises(SlackObjectFormationError):
modal_view.validate_json()

def test_load_home_tab_view_001(self):
with open("tests/data/view_home_001.json") as file:
self.verify_loaded_view_object(file)
Expand Down
6 changes: 6 additions & 0 deletions tests/web/test_web_client_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import unittest

import slack
from slack.web.classes.blocks import DividerBlock
from slack.web.classes.views import View
from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server


Expand Down Expand Up @@ -273,6 +275,10 @@ def test_coverage(self):
self.api_methods_to_call.remove(method(presence="away")["method"])
elif method_name == "views_open":
self.api_methods_to_call.remove(method(trigger_id="123123", view={})["method"])
method(
trigger_id="123123",
view=View(type="modal", blocks=[DividerBlock()])
)
elif method_name == "views_publish":
self.api_methods_to_call.remove(method(user_id="U123", view={})["method"])
elif method_name == "views_push":
Expand Down