diff --git a/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 new file mode 100644 index 0000000..59b5f54 --- /dev/null +++ b/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -0,0 +1,203 @@ +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; +import InputValidation from 'discourse/models/input-validation'; + +export default Ember.Controller.extend({ + regularPollType: 'regular', + numberPollType: 'number', + multiplePollType: 'multiple', + + init() { + this._super(); + this._setupPoll(); + }, + + @computed("regularPollType", "numberPollType", "multiplePollType") + pollTypes(regularPollType, numberPollType, multiplePollType) { + let types = []; + + types.push({ name: I18n.t("poll.ui_builder.poll_type.regular"), value: regularPollType }); + // TODO number & multiple do not work for polls with voter groups + types.push({ name: I18n.t("poll.ui_builder.poll_type.number"), value: numberPollType }); + types.push({ name: I18n.t("poll.ui_builder.poll_type.multiple"), value: multiplePollType }); + + return types; + }, + + @computed("pollType", "regularPollType") + isRegular(pollType, regularPollType) { + return pollType === regularPollType; + }, + + @computed("pollType", "pollOptionsCount", "multiplePollType") + isMultiple(pollType, count, multiplePollType) { + return (pollType === multiplePollType) && count > 0; + }, + + @computed("pollType", "numberPollType") + isNumber(pollType, numberPollType) { + return pollType === numberPollType; + }, + + @computed("isRegular") + showMinMax(isRegular) { + return !isRegular; + }, + + @computed("pollOptions") + pollOptionsCount(pollOptions) { + if (pollOptions.length === 0) return 0; + + let length = 0; + + pollOptions.split("\n").forEach(option => { + if (option.length !== 0) length += 1; + }); + + return length; + }, + + @observes("isMultiple", "isNumber", "pollOptionsCount") + _setPollMax() { + const isMultiple = this.get("isMultiple"); + const isNumber = this.get("isNumber"); + if (!isMultiple && !isNumber) return; + + if (isMultiple) { + this.set("pollMax", this.get("pollOptionsCount")); + } else if (isNumber) { + this.set("pollMax", this.siteSettings.poll_maximum_options); + } + }, + + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount") + pollMinOptions(isRegular, isMultiple, isNumber, count) { + if (isRegular) return; + + if (isMultiple) { + return this._comboboxOptions(1, count + 1); + } else if (isNumber) { + return this._comboboxOptions(1, this.siteSettings.poll_maximum_options + 1); + } + }, + + @computed("isRegular", "isMultiple", "isNumber", "pollOptionsCount", "pollMin", "pollStep") + pollMaxOptions(isRegular, isMultiple, isNumber, count, pollMin, pollStep) { + if (isRegular) return; + const pollMinInt = parseInt(pollMin) || 1; + + if (isMultiple) { + return this._comboboxOptions(pollMinInt + 1, count + 1); + } else if (isNumber) { + let pollStepInt = parseInt(pollStep, 10); + if (pollStepInt < 1) { + pollStepInt = 1; + } + return this._comboboxOptions(pollMinInt + 1, pollMinInt + (this.siteSettings.poll_maximum_options * pollStepInt)); + } + }, + + @computed("isNumber", "pollMax") + pollStepOptions(isNumber, pollMax) { + if (!isNumber) return; + return this._comboboxOptions(1, (parseInt(pollMax) || 1) + 1); + }, + + @computed("isNumber", "showMinMax", "pollType", "publicPoll", "voterGroups", "pollOptions", "pollMin", "pollMax", "pollStep") + pollOutput(isNumber, showMinMax, pollType, publicPoll, voterGroups, pollOptions, pollMin, pollMax, pollStep) { + let pollHeader = '[poll'; + let output = ''; + + const match = this.get("toolbarEvent").getText().match(/\[poll(\s+name=[^\s\]]+)*.*\]/igm); + + if (match) { + pollHeader += ` name=poll${match.length + 1}`; + }; + + let step = pollStep; + if (step < 1) { + step = 1; + } + + if (pollType) pollHeader += ` type=${pollType}`; + if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`; + if (pollMax) pollHeader += ` max=${pollMax}`; + if (isNumber) pollHeader += ` step=${step}`; + if (publicPoll) pollHeader += ' public=true'; + if (voterGroups) pollHeader += ' groups=true'; + pollHeader += ']'; + output += `${pollHeader}\n`; + + if (pollOptions.length > 0 && !isNumber) { + pollOptions.split("\n").forEach(option => { + if (option.length !== 0) output += `* ${option}\n`; + }); + } + + output += '[/poll]'; + return output; + }, + + @computed("pollOptionsCount", "isRegular", "isMultiple", "isNumber", "pollMin", "pollMax") + disableInsert(count, isRegular, isMultiple, isNumber, pollMin, pollMax) { + return (isRegular && count < 2) || (isMultiple && count < pollMin && pollMin >= pollMax) || (isNumber ? false : (count < 2)); + }, + + @computed("pollMin", "pollMax") + minMaxValueValidation(pollMin, pollMax) { + let options = { ok: true }; + + if (pollMin >= pollMax) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.invalid_values") }; + } + + return InputValidation.create(options); + }, + + @computed("pollStep") + minStepValueValidation(pollStep) { + let options = { ok: true }; + + if (pollStep < 1) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.min_step_value") }; + } + + return InputValidation.create(options); + }, + + @computed("disableInsert") + minNumOfOptionsValidation(disableInsert) { + let options = { ok: true }; + + if (disableInsert) { + options = { failed: true, reason: I18n.t("poll.ui_builder.help.options_count") }; + } + + return InputValidation.create(options); + }, + + _comboboxOptions(start_index, end_index) { + return _.range(start_index, end_index).map(number => { + return { value: number, name: number }; + }); + }, + + _setupPoll() { + this.setProperties({ + pollType: 'regular', + publicPoll: false, + voterGroups: false, + pollOptions: '', + pollMin: 1, + pollMax: null, + pollStep: 1 + }); + }, + + actions: { + insertPoll() { + this.get("toolbarEvent").addText(this.get("pollOutput")); + this.send("closeModal"); + this._setupPoll(); + } + } +}); diff --git a/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs b/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs new file mode 100644 index 0000000..83b360c --- /dev/null +++ b/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs @@ -0,0 +1,69 @@ +{{#d-modal-body title="poll.ui_builder.title" class="poll-ui-builder"}} +
+{{/d-modal-body}} + + diff --git a/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 b/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 new file mode 100644 index 0000000..9dbe65a --- /dev/null +++ b/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 @@ -0,0 +1,40 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; +import computed from 'ember-addons/ember-computed-decorators'; +import showModal from 'discourse/lib/show-modal'; + +function initializePollUIBuilder(api) { + api.modifyClass('controller:composer', { + @computed('siteSettings.poll_enabled', 'siteSettings.poll_minimum_trust_level_to_create') + canBuildPoll(pollEnabled, minimumTrustLevel) { + return pollEnabled && + this.currentUser && + ( + this.currentUser.staff || + this.currentUser.trust_level >= minimumTrustLevel + ); + }, + + actions: { + showPollBuilder() { + showModal('poll-ui-builder').set('toolbarEvent', this.get('toolbarEvent')); + } + } + }); + + api.addToolbarPopupMenuOptionsCallback(function() { + return { + action: 'showPollBuilder', + icon: 'bar-chart-o', + label: 'poll.ui_builder.title', + condition: 'canBuildPoll' + }; + }); +} + +export default { + name: 'add-poll-ui-builder', + + initialize() { + withPluginApi('0.8.7', initializePollUIBuilder); + } +}; diff --git a/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/poll/assets/javascripts/initializers/extend-for-poll.js.es6 new file mode 100644 index 0000000..05df7aa --- /dev/null +++ b/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -0,0 +1,99 @@ +import { withPluginApi } from 'discourse/lib/plugin-api'; +import { observes } from "ember-addons/ember-computed-decorators"; +import { getRegister } from 'discourse-common/lib/get-owner'; +import WidgetGlue from 'discourse/widgets/glue'; + +function initializePolls(api) { + const register = getRegister(api); + + api.modifyClass('controller:topic', { + subscribe(){ + this._super(); + this.messageBus.subscribe("/polls/" + this.get("model.id"), msg => { + const post = this.get('model.postStream').findLoadedPost(msg.post_id); + if (post) { + post.set('polls', msg.polls); + } + }); + }, + unsubscribe(){ + this.messageBus.unsubscribe('/polls/*'); + this._super(); + } + }); + + api.modifyClass('model:post', { + _polls: null, + pollsObject: null, + + // we need a proper ember object so it is bindable + @observes("polls") + pollsChanged() { + const polls = this.get("polls"); + if (polls) { + this._polls = this._polls || {}; + _.map(polls, (v,k) => { + const existing = this._polls[k]; + if (existing) { + this._polls[k].setProperties(v); + } else { + this._polls[k] = Em.Object.create(v); + } + }); + this.set("pollsObject", this._polls); + _glued.forEach(g => g.queueRerender()); + } + } + }); + + const _glued = []; + function attachPolls($elem, helper) { + const $polls = $('.poll', $elem); + if (!$polls.length) { return; } + + const post = helper.getModel(); + api.preventCloak(post.id); + const votes = post.get('polls_votes') || {}; + + post.pollsChanged(); + + const polls = post.get("pollsObject"); + if (!polls) { return; } + + $polls.each((idx, pollElem) => { + const $poll = $(pollElem); + const pollName = $poll.data("poll-name"); + const poll = polls[pollName]; + if (poll) { + const isMultiple = poll.get('type') === 'multiple'; + + const glue = new WidgetGlue('discourse-poll', register, { + id: `${pollName}-${post.id}`, + post, + poll, + vote: votes[pollName] ? votes[pollName]["options"] : [], + voterGroupId: votes[pollName] ? votes[pollName]["voter_group_id"] : -1, + isMultiple, + }); + glue.appendTo(pollElem); + _glued.push(glue); + } + }); + } + + function cleanUpPolls() { + _glued.forEach(g => g.cleanUp()); + } + + api.includePostAttributes("polls", "polls_votes"); + api.decorateCooked(attachPolls, { onlyStream: true }); + api.cleanupStream(cleanUpPolls); +} + +export default { + name: "extend-for-poll", + + initialize() { + withPluginApi('0.8.7', initializePolls); + } +}; diff --git a/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 b/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 new file mode 100644 index 0000000..e200636 --- /dev/null +++ b/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 @@ -0,0 +1,476 @@ +/*eslint no-bitwise:0 */ + +const DATA_PREFIX = "data-poll-"; +const DEFAULT_POLL_NAME = "poll"; +const WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status", "public", "groups"]; + +function getHelpText(count, min, max) { + + // default values + if (isNaN(min) || min < 1) { min = 1; } + if (isNaN(max) || max > count) { max = count; } + + // add some help text + let help; + + if (max > 0) { + if (min === max) { + if (min > 1) { + help = I18n.t("poll.multiple.help.x_options", { count: min }); + } + } else if (min > 1) { + if (max < count) { + help = I18n.t("poll.multiple.help.between_min_and_max_options", { min: min, max: max }); + } else { + help = I18n.t("poll.multiple.help.at_least_min_options", { count: min }); + } + } else if (max <= count) { + help = I18n.t("poll.multiple.help.up_to_max_options", { count: max }); + } + } + + return help; +} + +function replaceToken(tokens, target, list) { + let pos = tokens.indexOf(target); + let level = tokens[pos].level; + + tokens.splice(pos, 1, ...list); + list[0].map = target.map; + + // resequence levels + for(;posname
' attribute to uniquely identify your polls."
+ multiple_polls_with_same_name: "There are multiple polls with the same name: %{name}. Use the 'name
' attribute to uniquely identify your polls."
+
+ default_poll_must_have_at_least_2_options: "Poll must have at least 2 options."
+ named_poll_must_have_at_least_2_options: "Poll named %{name} must have at least 2 options."
+
+ default_poll_must_have_less_options:
+ one: "Poll must have less than 1 option."
+ other: "Poll must have less than %{count} options."
+ named_poll_must_have_less_options:
+ one: "Poll named %{name} must have less than 1 option."
+ other: "Poll named %{name} must have less than %{count} options."
+
+ default_poll_must_have_different_options: "Poll must have different options."
+ named_poll_must_have_different_options: "Poll named %{name} must have different options."
+
+ default_poll_with_multiple_choices_has_invalid_parameters: "Poll with multiple choices has invalid parameters."
+ named_poll_with_multiple_choices_has_invalid_parameters: "Poll named %{name} with multiple choice has invalid parameters."
+
+ requires_at_least_1_valid_option: "You must select at least 1 valid option."
+
+ default_cannot_be_made_public: "Poll with votes cannot be made public."
+ named_cannot_be_made_public: "Poll named %{name} has votes cannot be made public."
+
+ edit_window_expired:
+ cannot_change_polls: "You cannot add, remove or rename polls after the first %{minutes} minutes."
+ op_cannot_edit_options: "You cannot add or remove poll options after the first %{minutes} minutes. Please contact a moderator if you need to edit a poll option."
+ staff_cannot_add_or_remove_options: "You cannot add or remove poll options after the first %{minutes} minutes. You should close this topic and create a new one instead."
+
+ no_polls_associated_with_this_post: "No polls are associated with this post."
+ no_poll_with_this_name: "No poll named %{name} associated with this post."
+
+ post_is_deleted: "Cannot act on a deleted post."
+
+ topic_must_be_open_to_vote: "The topic must be open to vote."
+ poll_must_be_open_to_vote: "Poll must be open to vote."
+
+ topic_must_be_open_to_toggle_status: "The topic must be open to toggle status."
+ only_staff_or_op_can_toggle_status: "Only a staff member or the original poster can toggle a poll status."
+
+ insufficient_rights_to_create: "You are not allowed to create polls."
+
+ email:
+ link_to_poll: "Click to view the poll."
diff --git a/poll/config/settings.yml b/poll/config/settings.yml
new file mode 100644
index 0000000..4c891f1
--- /dev/null
+++ b/poll/config/settings.yml
@@ -0,0 +1,19 @@
+plugins:
+ poll_enabled:
+ default: true
+ client: true
+ poll_maximum_options:
+ default: 20
+ client: true
+ poll_edit_window_mins:
+ default: 5
+ poll_minimum_trust_level_to_create:
+ default: 0
+ client: true
+ enum: 'TrustLevelSetting'
+ poll_voter_info_endpoint:
+ default: ''
+ client: true
+ poll_constitution_link:
+ default: ''
+ client: true
diff --git a/poll/lib/polls_updater.rb b/poll/lib/polls_updater.rb
new file mode 100644
index 0000000..e9a00b7
--- /dev/null
+++ b/poll/lib/polls_updater.rb
@@ -0,0 +1,151 @@
+module DiscoursePoll
+ class PollsUpdater
+ VALID_POLLS_CONFIGS = %w{type min max public groups}.map(&:freeze)
+
+ def self.update(post, polls)
+ # load previous polls
+ previous_polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] || {}
+
+ # extract options
+ current_option_ids = extract_option_ids(polls)
+ previous_option_ids = extract_option_ids(previous_polls)
+
+ # are the polls different?
+ if polls_updated?(polls, previous_polls) || (current_option_ids != previous_option_ids)
+ has_votes = total_votes(previous_polls) > 0
+
+ # outside of the edit window?
+ poll_edit_window_mins = SiteSetting.poll_edit_window_mins
+
+ if post.created_at < poll_edit_window_mins.minutes.ago && has_votes
+ # cannot add/remove/rename polls
+ if polls.keys.sort != previous_polls.keys.sort
+ post.errors.add(:base, I18n.t(
+ "poll.edit_window_expired.cannot_change_polls", minutes: poll_edit_window_mins
+ ))
+
+ return
+ end
+
+ # deal with option changes
+ if User.staff.pluck(:id).include?(post.last_editor_id)
+ # staff can only edit options
+ polls.each_key do |poll_name|
+ if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size && previous_polls[poll_name]["voters"].to_i > 0
+ post.errors.add(:base, I18n.t(
+ "poll.edit_window_expired.staff_cannot_add_or_remove_options",
+ minutes: poll_edit_window_mins
+ ))
+
+ return
+ end
+ end
+ else
+ # OP cannot edit poll options
+ post.errors.add(:base, I18n.t(
+ "poll.edit_window_expired.op_cannot_edit_options",
+ minutes: poll_edit_window_mins
+ ))
+
+ return
+ end
+ end
+
+ # try to merge votes
+ polls.each_key do |poll_name|
+ next unless previous_polls.has_key?(poll_name)
+ return if has_votes && private_to_public_poll?(post, previous_polls, polls, poll_name)
+
+ # when the # of options has changed, reset all the votes
+ if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size
+ PostCustomField.where(post_id: post.id, name: DiscoursePoll::VOTES_CUSTOM_FIELD).destroy_all
+ post.clear_custom_fields
+ next
+ end
+
+ polls[poll_name]["voters"] = previous_polls[poll_name]["voters"]
+
+ if previous_polls[poll_name].has_key?("anonymous_voters")
+ polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"]
+ end
+
+ previous_options = previous_polls[poll_name]["options"]
+ public_poll = polls[poll_name]["public"] == "true"
+
+ polls[poll_name]["options"].each_with_index do |option, index|
+ previous_option = previous_options[index]
+ option["votes"] = previous_option["votes"]
+
+ if previous_option["id"] != option["id"]
+ if votes_fields = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
+ votes_fields.each do |key, value|
+ next unless value[poll_name]
+ index = value[poll_name].index(previous_option["id"])
+ votes_fields[key][poll_name][index] = option["id"] if index
+ end
+ end
+ end
+
+ if previous_option.has_key?("anonymous_votes")
+ option["anonymous_votes"] = previous_option["anonymous_votes"]
+ end
+
+ if public_poll && previous_option.has_key?("voter_ids")
+ option["voter_ids"] = previous_option["voter_ids"]
+ end
+ end
+ end
+
+ # immediately store the polls
+ post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
+ post.save_custom_fields(true)
+
+ # publish the changes
+ MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
+ end
+ end
+
+ def self.polls_updated?(current_polls, previous_polls)
+ return true if (current_polls.keys.sort != previous_polls.keys.sort)
+
+ current_polls.each_key do |poll_name|
+ if !previous_polls[poll_name] ||
+ (current_polls[poll_name].values_at(*VALID_POLLS_CONFIGS) != previous_polls[poll_name].values_at(*VALID_POLLS_CONFIGS))
+
+ return true
+ end
+ end
+
+ false
+ end
+
+ def self.extract_option_ids(polls)
+ polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort
+ end
+
+ def self.total_votes(polls)
+ polls.map { |key, value| value["voters"].map {|k,v| v.to_i}.sum }.sum
+ end
+
+ private
+
+ def self.private_to_public_poll?(post, previous_polls, current_polls, poll_name)
+ _previous_poll = previous_polls[poll_name]
+ current_poll = current_polls[poll_name]
+
+ if previous_polls["public"].nil? && current_poll["public"] == "true"
+ error =
+ if poll_name == DiscoursePoll::DEFAULT_POLL_NAME
+ I18n.t("poll.default_cannot_be_made_public")
+ else
+ I18n.t("poll.named_cannot_be_made_public", name: poll_name)
+ end
+
+ post.errors.add(:base, error)
+ return true
+ end
+
+ false
+ end
+ end
+end
diff --git a/poll/lib/polls_validator.rb b/poll/lib/polls_validator.rb
new file mode 100644
index 0000000..2321f7f
--- /dev/null
+++ b/poll/lib/polls_validator.rb
@@ -0,0 +1,113 @@
+module DiscoursePoll
+ class PollsValidator
+ def initialize(post)
+ @post = post
+ end
+
+ def validate_polls
+ polls = {}
+
+ extracted_polls = DiscoursePoll::Poll::extract(@post.raw, @post.topic_id, @post.user_id)
+
+ extracted_polls.each do |poll|
+ # polls should have a unique name
+ return false unless unique_poll_name?(polls, poll)
+
+ # options must be unique
+ return false unless unique_options?(poll)
+
+ # at least 2 options
+ return false unless at_least_two_options?(poll)
+
+ # maximum # of options
+ return false unless valid_number_of_options?(poll)
+
+ # poll with multiple choices
+ return false unless valid_multiple_choice_settings?(poll)
+
+ # store the valid poll
+ polls[poll["name"]] = poll
+ end
+
+ polls
+ end
+
+ private
+
+ def unique_poll_name?(polls, poll)
+ if polls.has_key?(poll["name"])
+ if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
+ @post.errors.add(:base, I18n.t("poll.multiple_polls_without_name"))
+ else
+ @post.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"]))
+ end
+
+ return false
+ end
+
+ true
+ end
+
+ def unique_options?(poll)
+ if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size
+ if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
+ @post.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options"))
+ else
+ @post.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"]))
+ end
+
+ return false
+ end
+
+ true
+ end
+
+ def at_least_two_options?(poll)
+ if poll["options"].size < 2
+ if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
+ @post.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options"))
+ else
+ @post.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"]))
+ end
+
+ return false
+ end
+
+ true
+ end
+
+ def valid_number_of_options?(poll)
+ if poll["options"].size > SiteSetting.poll_maximum_options
+ if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
+ @post.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options))
+ else
+ @post.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options))
+ end
+
+ return false
+ end
+
+ true
+ end
+
+ def valid_multiple_choice_settings?(poll)
+ if poll["type"] == "multiple"
+ num_of_options = poll["options"].size
+ min = (poll["min"].presence || 1).to_i
+ max = (poll["max"].presence || num_of_options).to_i
+
+ if min > max || min <= 0 || max <= 0 || max > num_of_options || min >= num_of_options
+ if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
+ @post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
+ else
+ @post.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"]))
+ end
+
+ return false
+ end
+ end
+
+ true
+ end
+ end
+end
diff --git a/poll/lib/post_validator.rb b/poll/lib/post_validator.rb
new file mode 100644
index 0000000..7301fdd
--- /dev/null
+++ b/poll/lib/post_validator.rb
@@ -0,0 +1,22 @@
+module DiscoursePoll
+ class PostValidator
+ def initialize(post)
+ @post = post
+ end
+
+ def validate_post
+ min_trust_level = SiteSetting.poll_minimum_trust_level_to_create
+ trusted = @post&.user&.staff? ||
+ @post&.user&.trust_level >= TrustLevel[min_trust_level]
+
+ if !trusted
+ message = I18n.t("poll.insufficient_rights_to_create")
+
+ @post.errors.add(:base, message)
+ return false
+ end
+
+ true
+ end
+ end
+end
diff --git a/poll/plugin.rb b/poll/plugin.rb
new file mode 100644
index 0000000..57a90da
--- /dev/null
+++ b/poll/plugin.rb
@@ -0,0 +1,411 @@
+# name: poll
+# about: modified from official poll plugin for Discourse (not currently compatible with official poll plugin)
+# url: https://github.com/crowdresearch/voting-plugin
+
+register_asset "stylesheets/common/poll.scss"
+register_asset "stylesheets/common/poll-ui-builder.scss"
+register_asset "stylesheets/desktop/poll.scss", :desktop
+register_asset "stylesheets/mobile/poll.scss", :mobile
+
+PLUGIN_NAME ||= "discourse_poll".freeze
+
+DATA_PREFIX ||= "data-poll-".freeze
+
+after_initialize do
+ module ::DiscoursePoll
+ DEFAULT_POLL_NAME ||= "poll".freeze
+ POLLS_CUSTOM_FIELD ||= "polls".freeze
+ VOTES_CUSTOM_FIELD ||= "polls-votes".freeze
+
+ autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator"
+ autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator"
+ autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater"
+
+ class Engine < ::Rails::Engine
+ engine_name PLUGIN_NAME
+ isolate_namespace DiscoursePoll
+ end
+ end
+
+ class DiscoursePoll::Poll
+ class << self
+
+ def vote(post_id, poll_name, options, voter_group_id, user)
+ DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
+ user_id = user.id
+ post = Post.find_by(id: post_id)
+
+ # post must not be deleted
+ if post.nil? || post.trashed?
+ raise StandardError.new I18n.t("poll.post_is_deleted")
+ end
+
+ # topic must not be archived
+ if post.topic.try(:archived)
+ raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote")
+ end
+
+ polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
+
+ raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank?
+
+ poll = polls[poll_name]
+
+ raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank?
+ raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open"
+ public_poll = (poll["public"] == "true")
+
+ # remove options that aren't available in the poll
+ available_options = poll["options"].map { |o| o["id"] }.to_set
+ options.select! { |o| available_options.include?(o) }
+
+ raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
+
+ poll["voters"] = Hash.new(0)
+ all_options = Hash.new { |h, k| h[k] = Hash.new(0) }
+
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] ||= {}
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {}
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] ||= {}
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name]["options"] = options
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name]["voter_group_id"] = voter_group_id
+
+ post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].each do |_, user_votes|
+ next unless votes = user_votes[poll_name]
+
+ vg_id = votes["voter_group_id"]
+
+ votes["options"].each do |option|
+ all_options[option][vg_id] += 1
+ end
+
+ poll["voters"][vg_id] += 1 if (available_options & votes["options"].to_set).size > 0
+ end
+
+ poll["options"].each do |option|
+ option["votes"] = all_options[option["id"]]
+
+ if public_poll
+ option["voter_ids"] ||= []
+
+ if options.include?(option["id"])
+ option["voter_ids"] << user_id if !option["voter_ids"].include?(user_id)
+ else
+ option["voter_ids"].delete(user_id)
+ end
+ end
+ end
+
+ post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
+ post.save_custom_fields(true)
+
+ payload = { post_id: post_id, polls: polls }
+
+ if public_poll
+ payload.merge!(user: UserNameSerializer.new(user).serializable_hash)
+ end
+
+ MessageBus.publish("/polls/#{post.topic_id}", payload)
+
+ return [poll, options]
+ end
+ end
+
+ def toggle_status(post_id, poll_name, status, user_id)
+ DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
+ post = Post.find_by(id: post_id)
+
+ # post must not be deleted
+ if post.nil? || post.trashed?
+ raise StandardError.new I18n.t("poll.post_is_deleted")
+ end
+
+ # topic must not be archived
+ if post.topic.try(:archived)
+ raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status")
+ end
+
+ user = User.find_by(id: user_id)
+
+ # either staff member or OP
+ unless user_id == post.user_id || user.try(:staff?)
+ raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status")
+ end
+
+ polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
+
+ raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank?
+ raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank?
+
+ polls[poll_name]["status"] = status
+
+ post.save_custom_fields(true)
+
+ MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
+
+ polls[poll_name]
+ end
+ end
+
+ def extract(raw, topic_id, user_id = nil)
+ # TODO: we should fix the callback mess so that the cooked version is available
+ # in the validators instead of cooking twice
+ cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id)
+ parsed = Nokogiri::HTML(cooked)
+
+ extracted_polls = []
+
+ # extract polls
+ parsed.css("div.poll").each do |p|
+ poll = { "options" => [], "voters" => Hash.new(0) }
+
+ # extract attributes
+ p.attributes.values.each do |attribute|
+ if attribute.name.start_with?(DATA_PREFIX)
+ poll[attribute.name[DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "")
+ end
+ end
+
+ # extract options
+ p.css("li[#{DATA_PREFIX}option-id]").each do |o|
+ option_id = o.attributes[DATA_PREFIX + "option-id"].value || ""
+ poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => Hash.new(0) }
+ end
+
+ # add the poll
+ extracted_polls << poll
+ end
+
+ extracted_polls
+ end
+ end
+ end
+
+ require_dependency "application_controller"
+
+ class DiscoursePoll::PollsController < ::ApplicationController
+ requires_plugin PLUGIN_NAME
+
+ before_action :ensure_logged_in, except: [:voters]
+
+ def vote
+ post_id = params.require(:post_id)
+ poll_name = params.require(:poll_name)
+ options = params.require(:options)
+ voter_group_id = params.require(:voter_group_id)
+
+ begin
+ poll, options = DiscoursePoll::Poll.vote(post_id, poll_name, options, voter_group_id, current_user)
+ render json: { poll: poll, vote: options }
+ rescue StandardError => e
+ render_json_error e.message
+ end
+ end
+
+ def toggle_status
+ post_id = params.require(:post_id)
+ poll_name = params.require(:poll_name)
+ status = params.require(:status)
+ user_id = current_user.id
+
+ begin
+ poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, user_id)
+ render json: { poll: poll }
+ rescue StandardError => e
+ render_json_error e.message
+ end
+ end
+
+ def voters
+ post_id = params.require(:post_id)
+ poll_name = params.require(:poll_name)
+
+ post = Post.find_by(id: post_id)
+ raise Discourse::InvalidParameters.new("post_id is invalid") if !post
+
+ poll = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD][poll_name]
+ raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll
+
+ voter_limit = (params[:voter_limit] || 25).to_i
+ voter_limit = 0 if voter_limit < 0
+ voter_limit = 50 if voter_limit > 50
+
+ user_ids = []
+ options = poll["options"]
+
+ if poll["type"] != "number"
+
+ per_option_voters = {}
+
+ options.each do |option|
+ if (params[:option_id])
+ next unless option["id"] == params[:option_id].to_s
+ end
+
+ next unless option["voter_ids"]
+ voters = option["voter_ids"].slice((params[:offset].to_i || 0) * voter_limit, voter_limit)
+ per_option_voters[option["id"]] = Set.new(voters)
+ user_ids << voters
+ end
+
+ user_ids.flatten!
+ user_ids.uniq!
+
+ poll_votes = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
+
+ result = {}
+
+ User.where(id: user_ids).map do |user|
+ user_hash = UserNameSerializer.new(user).serializable_hash
+
+ poll_votes[user.id.to_s][poll_name].each do |option_id|
+ if (params[:option_id])
+ next unless option_id == params[:option_id].to_s
+ end
+
+ voters = per_option_voters[option_id]
+ # we may have a user from a different vote
+ next unless voters.include?(user.id)
+
+ result[option_id] ||= []
+ result[option_id] << user_hash
+ end
+ end
+ else
+ user_ids = options.map { |option| option["voter_ids"] }.sort!
+ user_ids.flatten!
+ user_ids.uniq!
+ user_ids = user_ids.slice((params[:offset].to_i || 0) * voter_limit, voter_limit)
+
+ result = []
+
+ User.where(id: user_ids).map do |user|
+ result << UserNameSerializer.new(user).serializable_hash
+ end
+ end
+
+ render json: { poll_name => result }
+ end
+ end
+
+ DiscoursePoll::Engine.routes.draw do
+ put "/vote" => "polls#vote"
+ put "/toggle_status" => "polls#toggle_status"
+ get "/voters" => 'polls#voters'
+ end
+
+ Discourse::Application.routes.append do
+ mount ::DiscoursePoll::Engine, at: "/polls"
+ end
+
+ Post.class_eval do
+ attr_accessor :polls
+
+ after_save do
+ next if self.polls.blank? || !self.polls.is_a?(Hash)
+
+ post = self
+ polls = self.polls
+
+ DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do
+ post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
+ post.save_custom_fields(true)
+ end
+ end
+ end
+
+ validate(:post, :validate_polls) do |force = nil|
+ # only care when raw has changed!
+ return unless self.raw_changed? || force
+
+ validator = DiscoursePoll::PollsValidator.new(self)
+ return unless (polls = validator.validate_polls)
+
+ if !polls.empty?
+ validator = DiscoursePoll::PostValidator.new(self)
+ return unless validator.validate_post
+ end
+
+ # are we updating a post?
+ if self.id.present?
+ DistributedMutex.synchronize("#{PLUGIN_NAME}-#{self.id}") do
+ DiscoursePoll::PollsUpdater.update(self, polls)
+ end
+ else
+ self.polls = polls
+ end
+
+ true
+ end
+
+ NewPostManager.add_handler(1) do |manager|
+ post = Post.new(raw: manager.args[:raw])
+
+ if !DiscoursePoll::PollsValidator.new(post).validate_polls
+ result = NewPostResult.new(:poll, false)
+
+ post.errors.full_messages.each do |message|
+ result.errors[:base] << message
+ end
+
+ result
+ else
+ manager.args["is_poll"] = true
+ nil
+ end
+ end
+
+ on(:approved_post) do |queued_post, created_post|
+ if queued_post.post_options["is_poll"]
+ created_post.validate_polls(true)
+ end
+ end
+
+ register_post_custom_field_type(DiscoursePoll::POLLS_CUSTOM_FIELD, :json)
+ register_post_custom_field_type(DiscoursePoll::VOTES_CUSTOM_FIELD, :json)
+
+ topic_view_post_custom_fields_whitelister do |user|
+ user ? [DiscoursePoll::POLLS_CUSTOM_FIELD, DiscoursePoll::VOTES_CUSTOM_FIELD] : [DiscoursePoll::POLLS_CUSTOM_FIELD]
+ end
+
+ on(:reduce_cooked) do |fragment, post|
+ if post.nil? || post.trashed?
+ fragment.css(".poll, [data-poll-name]").each(&:remove)
+ else
+ post_url = "#{Discourse.base_url}#{post.url}"
+ fragment.css(".poll, [data-poll-name]").each do |poll|
+ poll.replace ""
+ end
+ end
+ end
+
+ # tells the front-end we have a poll for that post
+ on(:post_created) do |post|
+ next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank?
+ MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id,
+ polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD])
+ end
+
+ add_to_serializer(:post, :polls, false) do
+ polls = post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].dup
+
+ polls.each do |_, poll|
+ poll["options"].each do |option|
+ option.delete("voter_ids")
+ end
+ end
+ end
+
+ add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? }
+
+ add_to_serializer(:post, :polls_votes, false) do
+ post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"]
+ end
+
+ add_to_serializer(:post, :include_polls_votes?) do
+ return unless scope.user
+ return unless post_custom_fields.present?
+ return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present?
+ post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}")
+ end
+end