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

New Governance Process SlackAdvancedVote to the Slack Plugin #225

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
5 changes: 2 additions & 3 deletions metagov/metagov/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def quality_is_greater(a, b):
return order.index(a) > order.index(b)


class Plugin(models.Model):
class Plugin(models.Model ):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

"""Represents an instance of an activated plugin."""

name = models.CharField(max_length=30, blank=True, help_text="Name of the plugin")
Expand Down Expand Up @@ -191,10 +191,9 @@ def start_process(self, process_name, callback_url=None, **kwargs):
"""Start a new GovernanceProcess"""
# Find the proxy class for the specified GovernanceProcess
cls = self.__get_process_cls(process_name)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this?

# Convert kwargs to Parameters (does schema validation and filling in default values)
params = Parameters(values=kwargs, schema=cls.input_schema)

# Create new process instance
new_process = cls.objects.create(name=process_name, callback_url=callback_url, plugin=self)
logger.debug(f"Created process: {new_process}")
Expand Down
21 changes: 15 additions & 6 deletions metagov/metagov/plugins/slack/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from metagov.core.handlers import PluginRequestHandler
from metagov.core.models import LinkQuality, LinkType, ProcessStatus
from metagov.core.plugin_manager import AuthorizationType
from metagov.plugins.slack.models import Slack, SlackEmojiVote
from metagov.plugins.slack.models import Slack, SlackEmojiVote, SlackAdvancedVote, ADVANCED_VOTE_ACTION_ID, VOTE_ACTION_ID
from requests.models import PreparedRequest

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,11 +61,20 @@ def handle_incoming_webhook(self, request):
if payload["type"] != "block_actions":
return
team_id = payload["team"]["id"]
for plugin in Slack.objects.filter(community_platform_id=team_id):
active_processes = SlackEmojiVote.objects.filter(plugin=plugin, status=ProcessStatus.PENDING.value)
for process in active_processes:
logger.info(f"Passing Slack interaction to {process}")
process.receive_webhook(request)
if len(payload["actions"]) > 0:
action_id_example = payload["actions"][0]["action_id"]
if action_id_example == VOTE_ACTION_ID:
for plugin in Slack.objects.filter(community_platform_id=team_id):
active_emoji_vote_processes = SlackEmojiVote.objects.filter(plugin=plugin, status=ProcessStatus.PENDING.value)
for process in active_emoji_vote_processes:
logger.info(f"Passing Slack interaction to {process}")
process.receive_webhook(request)
elif action_id_example.startswith(ADVANCED_VOTE_ACTION_ID):
for plugin in Slack.objects.filter(community_platform_id=team_id):
active_advanced_vote_processes = SlackAdvancedVote.objects.filter(plugin=plugin, status=ProcessStatus.PENDING.value)
for process in active_advanced_vote_processes:
logger.info(f"Passing Slack interaction to {process}")
process.receive_webhook(request)
return

# Assume that this is a request from the Slack Events API
Expand Down
166 changes: 166 additions & 0 deletions metagov/metagov/plugins/slack/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,169 @@ def construct_message_header(title, details=None):
if details:
text += f"{details}\n"
return text


ADVANCED_VOTE_ACTION_ID = "advanced_vote"

@Registry.governance_process
class SlackAdvancedVote(GovernanceProcess):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of calling this advanced vote, since this is somewhat vague, what about calling it SlackMultichoiceVote or SlackElectionVote?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this class meant to represent different kinds of more advanced votes?

name = "advanced-vote"
plugin_name = "slack"
input_schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"details": {"type": "string"},
"candidates": {
"type": "array",
"items": {"type": "string"},
"description": "a list of candidates to vote for; for each we will create a select button",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "a predefined options for users to select from",
},
"channel": {
"type": "string",
"description": "channel to post the vote in",
},
"eligible_voters": {
"type": "array",
"items": {"type": "string"},
"description": "list of users who are eligible to vote. if eligible_voters is provided and channel is not provided, creates vote in a private group message.",
},
"ineligible_voters": {
"type": "array",
"items": {"type": "string"},
"description": "list of users who are not eligible to vote",
},
"ineligible_voter_message": {
"type": "string",
"description": "message to display to ineligible voter when they attempt to cast a vote",
"default": "You are not eligible to vote in this poll.",
},
},
"required": ["title", "candidates", "options"],
}

class Meta:
proxy = True

def start(self, parameters: Parameters) -> None:
text = construct_message_header(parameters.title, parameters.details)
self.state.set("message_header", text)
self.state.set("candidates", parameters.candidates)
self.state.set("options", parameters.options)
self.state.set("parameters", parameters._json)

maybe_channel = parameters.channel
maybe_users = parameters.eligible_voters
if maybe_channel is None and (maybe_users is None or len(maybe_users) == 0):
raise ValidationError("eligible_voters or channel are required")



self.outcome = {"votes": {}}

blocks = self._construct_blocks()
blocks = json.dumps(blocks)

if maybe_channel:
response = self.plugin_inst.post_message(channel=maybe_channel, blocks=blocks)
else:
response = self.plugin_inst.post_message(users=maybe_users, blocks=blocks)

ts = response["ts"]
channel = response["channel"]

permalink_resp = self.plugin_inst.method(method_name="chat.getPermalink", channel=channel, message_ts=ts)

self.url = permalink_resp["permalink"]
self.outcome["channel"] = channel
self.outcome["message_ts"] = ts

self.status = ProcessStatus.PENDING.value
self.save()

def receive_webhook(self, request):
payload = json.loads(request.POST.get("payload"))

if payload["message"]["ts"] != self.outcome["message_ts"]:
return

logger.info(f"{self} received block action")
response_url = payload["response_url"]

for a in payload["actions"]:
if a["action_id"].startswith(ADVANCED_VOTE_ACTION_ID):
candidate = a["action_id"].split(".")[1]
selected_option = a["selected_option"]["value"]
user = payload["user"]["id"]

# If user is not eligible to vote, don't cast vote & show a message
if not self._is_eligible_voter(user):
message = self.state.get("parameters").get("ineligible_voter_message")
logger.debug(f"Ignoring vote from ineligible voter {user}")
self.plugin_inst.method(
method_name="chat.postEphemeral", channel=self.outcome["channel"], text=message, user=user
)
return

self._cast_vote(user, candidate, selected_option)

def _is_eligible_voter(self, user):
eligible_voters = self.state.get("parameters").get("eligible_voters")
if eligible_voters and user not in eligible_voters:
return False
ineligible_voters = self.state.get("parameters").get("ineligible_voters")
if ineligible_voters and user in ineligible_voters:
return False
return True

def _cast_vote(self, user: str, candidate: str, option: str):
# Update vote count for selected value
logger.debug(f"> {user} cast vote {option} for {candidate}")
if user not in self.outcome["votes"]:
self.outcome["votes"][user] = {}
self.outcome["votes"][user][candidate] = option
self.save()

def _construct_blocks(self, hide_buttons=False):
"""
Construct voting message blocks
"""
text = self.state.get("message_header")
candidates = self.state.get("candidates")
options = self.state.get("options")
votes = self.outcome["votes"]

blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
for idx, candidate in enumerate(candidates):
candidate_text = candidate
action_id = f"{ADVANCED_VOTE_ACTION_ID}.{candidate}"
vote_option_section = {"type": "section", "text": {"type": "mrkdwn", "text": candidate_text}}
vote_option_section["accessory"] = {
"action_id": action_id,
"type": "static_select",
"placeholder": {
"type": "plain_text",
"text": "Select an option"
},
"options": []
}
for idx, option in enumerate(options):
vote_option_section["accessory"]["options"].append({
"text": {
"type": "plain_text",
"text": option
},
"value": option
})
blocks.append(vote_option_section)
return blocks

def close(self):
# Set governnace process to completed
self.status = ProcessStatus.COMPLETED.value
self.save()