REFACTOR: use tables instead of custom fields for polls (#6359)

Co-authored-by: Guo Xiang Tan <tgx_world@hotmail.com>
This commit is contained in:
Régis Hanol
2018-11-19 14:50:00 +01:00
committed by GitHub
parent 86dafc1f25
commit 4459665dee
37 changed files with 1912 additions and 1573 deletions

View File

@ -1,5 +1,9 @@
class ChangeBounceScoreToFloat < ActiveRecord::Migration[5.2] class ChangeBounceScoreToFloat < ActiveRecord::Migration[5.2]
def change def up
change_column :user_stats, :bounce_score, :float change_column :user_stats, :bounce_score, :float
end end
def down
change_column :user_stats, :bounce_score, :integer
end
end end

View File

@ -0,0 +1,76 @@
class Poll < ActiveRecord::Base
# because we want to use the 'type' column and don't want to use STI
self.inheritance_column = nil
belongs_to :post
has_many :poll_options, dependent: :destroy
has_many :poll_votes
enum type: {
regular: 0,
multiple: 1,
number: 2,
}
enum status: {
open: 0,
closed: 1,
}
enum results: {
always: 0,
on_vote: 1,
on_close: 2,
}
enum visibility: {
secret: 0,
everyone: 1,
}
validates :min, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
validates :max, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
validates :step, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
def is_closed?
closed? || (close_at && close_at <= Time.zone.now)
end
def can_see_results?(user)
always? || is_closed? || (on_vote? && has_voted?(user))
end
def has_voted?(user)
user&.id && poll_votes.any? { |v| v.user_id == user.id }
end
def can_see_voters?(user)
everyone? && can_see_results?(user)
end
end
# == Schema Information
#
# Table name: polls
#
# id :bigint(8) not null, primary key
# post_id :bigint(8)
# name :string default("poll"), not null
# close_at :datetime
# type :integer default("regular"), not null
# status :integer default("open"), not null
# results :integer default("always"), not null
# visibility :integer default("secret"), not null
# min :integer
# max :integer
# step :integer
# anonymous_voters :integer
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_polls_on_post_id (post_id)
# index_polls_on_post_id_and_name (post_id,name) UNIQUE
#

View File

@ -0,0 +1,24 @@
class PollOption < ActiveRecord::Base
belongs_to :poll
has_many :poll_votes, dependent: :delete_all
default_scope { order(created_at: :asc) }
end
# == Schema Information
#
# Table name: poll_options
#
# id :bigint(8) not null, primary key
# poll_id :bigint(8)
# digest :string not null
# html :text not null
# anonymous_votes :integer
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_poll_options_on_poll_id (poll_id)
# index_poll_options_on_poll_id_and_digest (poll_id,digest) UNIQUE
#

View File

@ -0,0 +1,23 @@
class PollVote < ActiveRecord::Base
belongs_to :poll
belongs_to :poll_option
belongs_to :user
end
# == Schema Information
#
# Table name: poll_votes
#
# poll_id :bigint(8)
# poll_option_id :bigint(8)
# user_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_poll_votes_on_poll_id (poll_id)
# index_poll_votes_on_poll_id_and_poll_option_id_and_user_id (poll_id,poll_option_id,user_id) UNIQUE
# index_poll_votes_on_poll_option_id (poll_option_id)
# index_poll_votes_on_user_id (user_id)
#

View File

@ -0,0 +1,14 @@
class PollOptionSerializer < ApplicationSerializer
attributes :id, :html, :votes
def id
object.digest
end
def votes
# `size` instead of `count` to prevent N+1
object.poll_votes.size + object.anonymous_votes.to_i
end
end

View File

@ -0,0 +1,50 @@
class PollSerializer < ApplicationSerializer
attributes :name,
:type,
:status,
:public,
:results,
:min,
:max,
:step,
:options,
:voters,
:close
def public
true
end
def include_public?
object.everyone?
end
def include_min?
object.min.present? && (object.number? || object.multiple?)
end
def include_max?
object.max.present? && (object.number? || object.multiple?)
end
def include_step?
object.step.present? && object.number?
end
def options
object.poll_options.map { |o| PollOptionSerializer.new(o, root: false).as_json }
end
def voters
object.poll_votes.map { |v| v.user_id }.uniq.count + object.anonymous_voters.to_i
end
def close
object.close_at
end
def include_close?
object.close_at.present?
end
end

View File

@ -9,6 +9,10 @@ export default Ember.Controller.extend({
numberPollType: "number", numberPollType: "number",
multiplePollType: "multiple", multiplePollType: "multiple",
alwaysPollResult: "always",
votePollResult: "on_vote",
closedPollResult: "on_close",
init() { init() {
this._super(); this._super();
this._setupPoll(); this._setupPoll();
@ -32,6 +36,24 @@ export default Ember.Controller.extend({
]; ];
}, },
@computed("alwaysPollResult", "votePollResult", "closedPollResult")
pollResults(alwaysPollResult, votePollResult, closedPollResult) {
return [
{
name: I18n.t("poll.ui_builder.poll_result.always"),
value: alwaysPollResult
},
{
name: I18n.t("poll.ui_builder.poll_result.vote"),
value: votePollResult
},
{
name: I18n.t("poll.ui_builder.poll_result.closed"),
value: closedPollResult
}
];
},
@computed("pollType", "regularPollType") @computed("pollType", "regularPollType")
isRegular(pollType, regularPollType) { isRegular(pollType, regularPollType) {
return pollType === regularPollType; return pollType === regularPollType;
@ -128,6 +150,7 @@ export default Ember.Controller.extend({
"isNumber", "isNumber",
"showMinMax", "showMinMax",
"pollType", "pollType",
"pollResult",
"publicPoll", "publicPoll",
"pollOptions", "pollOptions",
"pollMin", "pollMin",
@ -141,6 +164,7 @@ export default Ember.Controller.extend({
isNumber, isNumber,
showMinMax, showMinMax,
pollType, pollType,
pollResult,
publicPoll, publicPoll,
pollOptions, pollOptions,
pollMin, pollMin,
@ -167,6 +191,7 @@ export default Ember.Controller.extend({
} }
if (pollType) pollHeader += ` type=${pollType}`; if (pollType) pollHeader += ` type=${pollType}`;
if (pollResult) pollHeader += ` results=${pollResult}`;
if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`; if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`;
if (pollMax) pollHeader += ` max=${pollMax}`; if (pollMax) pollHeader += ` max=${pollMax}`;
if (isNumber) pollHeader += ` step=${step}`; if (isNumber) pollHeader += ` step=${step}`;

View File

@ -8,6 +8,15 @@
valueAttribute="value"}} valueAttribute="value"}}
</div> </div>
<div class="input-group poll-select">
<label class="input-group-label">{{i18n 'poll.ui_builder.poll_result.label'}}</label>
{{combo-box content=pollResults
value=pollResult
allowInitialValueMutation=true
valueAttribute="value"}}
</div>
{{#if showMinMax}} {{#if showMinMax}}
<div class="input-group poll-number"> <div class="input-group poll-number">
{{input-tip validation=minMaxValueValidation}} {{input-tip validation=minMaxValueValidation}}

View File

@ -39,12 +39,12 @@ function initializePolls(api) {
const polls = this.get("polls"); const polls = this.get("polls");
if (polls) { if (polls) {
this._polls = this._polls || {}; this._polls = this._polls || {};
_.map(polls, (v, k) => { polls.forEach(p => {
const existing = this._polls[k]; const existing = this._polls[p.name];
if (existing) { if (existing) {
this._polls[k].setProperties(v); this._polls[p.name].setProperties(p);
} else { } else {
this._polls[k] = Em.Object.create(v); this._polls[p.name] = Em.Object.create(p);
} }
}); });
this.set("pollsObject", this._polls); this.set("pollsObject", this._polls);
@ -81,14 +81,11 @@ function initializePolls(api) {
const pollName = $poll.data("poll-name"); const pollName = $poll.data("poll-name");
const poll = polls[pollName]; const poll = polls[pollName];
if (poll) { if (poll) {
const isMultiple = poll.get("type") === "multiple";
const glue = new WidgetGlue("discourse-poll", register, { const glue = new WidgetGlue("discourse-poll", register, {
id: `${pollName}-${post.id}`, id: `${pollName}-${post.id}`,
post, post,
poll, poll,
vote: votes[pollName] || [], vote: votes[pollName] || []
isMultiple
}); });
glue.appendTo(pollElem); glue.appendTo(pollElem);
_glued.push(glue); _glued.push(glue);

View File

@ -3,15 +3,16 @@
const DATA_PREFIX = "data-poll-"; const DATA_PREFIX = "data-poll-";
const DEFAULT_POLL_NAME = "poll"; const DEFAULT_POLL_NAME = "poll";
const WHITELISTED_ATTRIBUTES = [ const WHITELISTED_ATTRIBUTES = [
"type", "close",
"name",
"min",
"max", "max",
"step", "min",
"name",
"order", "order",
"status",
"public", "public",
"close" "results",
"status",
"step",
"type",
]; ];
function replaceToken(tokens, target, list) { function replaceToken(tokens, target, list) {

View File

@ -13,11 +13,14 @@ function optionHtml(option) {
return new RawHtml({ html: `<span>${option.html}</span>` }); return new RawHtml({ html: `<span>${option.html}</span>` });
} }
function fetchVoters(payload) { function infoTextHtml(text) {
return ajax("/polls/voters.json", { return new RawHtml({
type: "get", html: `<span class="info-text">${text}</span>`
data: payload });
}).catch(error => { }
function _fetchVoters(data) {
return ajax("/polls/voters.json", { data }).catch(error => {
if (error) { if (error) {
popupAjaxError(error); popupAjaxError(error);
} else { } else {
@ -34,19 +37,20 @@ createWidget("discourse-poll-option", {
}, },
html(attrs) { html(attrs) {
const result = []; const contents = [];
const { option, vote } = attrs; const { option, vote } = attrs;
const chosen = vote.indexOf(option.id) !== -1; const chosen = vote.includes(option.id);
if (attrs.isMultiple) { if (attrs.isMultiple) {
result.push(iconNode(chosen ? "check-square-o" : "square-o")); contents.push(iconNode(chosen ? "check-square-o" : "square-o"));
} else { } else {
result.push(iconNode(chosen ? "dot-circle-o" : "circle-o")); contents.push(iconNode(chosen ? "dot-circle-o" : "circle-o"));
} }
result.push(" ");
result.push(optionHtml(option));
return result; contents.push(" ");
contents.push(optionHtml(option));
return contents;
}, },
click(e) { click(e) {
@ -58,7 +62,7 @@ createWidget("discourse-poll-option", {
createWidget("discourse-poll-load-more", { createWidget("discourse-poll-load-more", {
tagName: "div.poll-voters-toggle-expand", tagName: "div.poll-voters-toggle-expand",
buildKey: attrs => `${attrs.id}-load-more`, buildKey: attrs => `load-more-${attrs.optionId}`,
defaultState() { defaultState() {
return { loading: false }; return { loading: false };
@ -72,50 +76,45 @@ createWidget("discourse-poll-load-more", {
click() { click() {
const { state } = this; const { state } = this;
if (state.loading) {
return; if (state.loading) return;
}
state.loading = true; state.loading = true;
return this.sendWidgetAction("loadMore").finally( return this.sendWidgetAction("loadMore").finally(
() => (state.loading = false) () => state.loading = false
); );
} }
}); });
createWidget("discourse-poll-voters", { createWidget("discourse-poll-voters", {
tagName: "ul.poll-voters-list", tagName: "ul.poll-voters-list",
buildKey: attrs => attrs.id(), buildKey: attrs => `poll-voters-${attrs.optionId}`,
defaultState() { defaultState() {
return { return {
loaded: "new", loaded: "new",
pollVoters: [], voters: [],
offset: 1 page: 1
}; };
}, },
fetchVoters() { fetchVoters() {
const { attrs, state } = this; const { attrs, state } = this;
if (state.loaded === "loading") {
return;
}
if (state.loaded === "loading") return;
state.loaded = "loading"; state.loaded = "loading";
return fetchVoters({ return _fetchVoters({
post_id: attrs.postId, post_id: attrs.postId,
poll_name: attrs.pollName, poll_name: attrs.pollName,
option_id: attrs.optionId, option_id: attrs.optionId,
offset: state.offset page: state.page
}).then(result => { }).then(result => {
state.loaded = "loaded"; state.loaded = "loaded";
state.offset += 1; state.page += 1;
const pollResult = result[attrs.pollName]; const newVoters = attrs.pollType === "number" ? result.voters : result.voters[attrs.optionId];
const newVoters = state.voters = [...new Set([...state.voters, ...newVoters])];
attrs.pollType === "number" ? pollResult : pollResult[attrs.optionId];
state.pollVoters = state.pollVoters.concat(newVoters);
this.scheduleRerender(); this.scheduleRerender();
}); });
@ -126,11 +125,11 @@ createWidget("discourse-poll-voters", {
}, },
html(attrs, state) { html(attrs, state) {
if (attrs.pollVoters && state.loaded === "new") { if (attrs.voters && state.loaded === "new") {
state.pollVoters = attrs.pollVoters; state.voters = attrs.voters;
} }
const contents = state.pollVoters.map(user => { const contents = state.voters.map(user => {
return h("li", [ return h("li", [
avatarFor("tiny", { avatarFor("tiny", {
username: user.username, username: user.username,
@ -140,10 +139,8 @@ createWidget("discourse-poll-voters", {
]); ]);
}); });
if (state.pollVoters.length < attrs.totalVotes) { if (state.voters.length < attrs.totalVotes) {
contents.push( contents.push(this.attach("discourse-poll-load-more", attrs));
this.attach("discourse-poll-load-more", { id: attrs.id() })
);
} }
return h("div.poll-voters", contents); return h("div.poll-voters", contents);
@ -152,27 +149,22 @@ createWidget("discourse-poll-voters", {
createWidget("discourse-poll-standard-results", { createWidget("discourse-poll-standard-results", {
tagName: "ul.results", tagName: "ul.results",
buildKey: attrs => `${attrs.id}-standard-results`, buildKey: attrs => `poll-standard-results-${attrs.id}`,
defaultState() { defaultState() {
return { return { loaded: false };
loaded: "new"
};
}, },
fetchVoters() { fetchVoters() {
const { attrs, state } = this; const { attrs, state } = this;
if (state.loaded === "new") { return _fetchVoters({
fetchVoters({ post_id: attrs.post.id,
post_id: attrs.post.id, poll_name: attrs.poll.get("name")
poll_name: attrs.poll.get("name") }).then(result => {
}).then(result => { state.voters = result.voters;
state.voters = result[attrs.poll.get("name")]; this.scheduleRerender();
state.loaded = "loaded"; });
this.scheduleRerender();
});
}
}, },
html(attrs, state) { html(attrs, state) {
@ -197,6 +189,11 @@ createWidget("discourse-poll-standard-results", {
} }
}); });
if (isPublic && !state.loaded) {
state.loaded = true;
this.fetchVoters();
}
const percentages = const percentages =
voters === 0 voters === 0
? Array(ordered.length).fill(0) ? Array(ordered.length).fill(0)
@ -206,8 +203,6 @@ createWidget("discourse-poll-standard-results", {
? percentages.map(Math.floor) ? percentages.map(Math.floor)
: evenRound(percentages); : evenRound(percentages);
if (isPublic) this.fetchVoters();
return ordered.map((option, idx) => { return ordered.map((option, idx) => {
const contents = []; const contents = [];
const per = rounded[idx].toString(); const per = rounded[idx].toString();
@ -230,12 +225,11 @@ createWidget("discourse-poll-standard-results", {
if (isPublic) { if (isPublic) {
contents.push( contents.push(
this.attach("discourse-poll-voters", { this.attach("discourse-poll-voters", {
id: () => `poll-voters-${option.id}`,
postId: attrs.post.id, postId: attrs.post.id,
optionId: option.id, optionId: option.id,
pollName: poll.get("name"), pollName: poll.get("name"),
totalVotes: option.votes, totalVotes: option.votes,
pollVoters: (state.voters && state.voters[option.id]) || [] voters: (state.voters && state.voters[option.id]) || []
}) })
); );
} }
@ -247,55 +241,51 @@ createWidget("discourse-poll-standard-results", {
}); });
createWidget("discourse-poll-number-results", { createWidget("discourse-poll-number-results", {
buildKey: attrs => `${attrs.id}-number-results`, buildKey: attrs => `poll-number-results-${attrs.id}`,
defaultState() { defaultState() {
return { return { loaded: false };
loaded: "new"
};
}, },
fetchVoters() { fetchVoters() {
const { attrs, state } = this; const { attrs, state } = this;
if (state.loaded === "new") { return _fetchVoters({
fetchVoters({ post_id: attrs.post.id,
post_id: attrs.post.id, poll_name: attrs.poll.get("name")
poll_name: attrs.poll.get("name") }).then(result => {
}).then(result => { state.voters = result.voters;
state.voters = result[attrs.poll.get("name")]; this.scheduleRerender();
state.loaded = "loaded"; });
this.scheduleRerender();
});
}
}, },
html(attrs, state) { html(attrs, state) {
const { poll } = attrs; const { poll } = attrs;
const isPublic = poll.get("public");
const totalScore = poll.get("options").reduce((total, o) => { const totalScore = poll.get("options").reduce((total, o) => {
return total + parseInt(o.html, 10) * parseInt(o.votes, 10); return total + parseInt(o.html, 10) * parseInt(o.votes, 10);
}, 0); }, 0);
const voters = poll.voters; const voters = poll.get("voters");
const average = voters === 0 ? 0 : round(totalScore / voters, -2); const average = voters === 0 ? 0 : round(totalScore / voters, -2);
const averageRating = I18n.t("poll.average_rating", { average }); const averageRating = I18n.t("poll.average_rating", { average });
const results = [ const contents = [
h( h(
"div.poll-results-number-rating", "div.poll-results-number-rating",
new RawHtml({ html: `<span>${averageRating}</span>` }) new RawHtml({ html: `<span>${averageRating}</span>` })
) )
]; ];
if (isPublic) { if (poll.get("public")) {
this.fetchVoters(); if (!state.loaded) {
state.loaded = true;
this.fetchVoters();
}
results.push( contents.push(
this.attach("discourse-poll-voters", { this.attach("discourse-poll-voters", {
id: () => `poll-voters-${poll.get("name")}`,
totalVotes: poll.get("voters"), totalVotes: poll.get("voters"),
pollVoters: state.voters || [], voters: state.voters || [],
postId: attrs.post.id, postId: attrs.post.id,
pollName: poll.get("name"), pollName: poll.get("name"),
pollType: poll.get("type") pollType: poll.get("type")
@ -303,22 +293,21 @@ createWidget("discourse-poll-number-results", {
); );
} }
return results; return contents;
} }
}); });
createWidget("discourse-poll-container", { createWidget("discourse-poll-container", {
tagName: "div.poll-container", tagName: "div.poll-container",
html(attrs) { html(attrs) {
const { poll } = attrs; const { poll } = attrs;
const options = poll.get("options");
if (attrs.showResults || attrs.isClosed) { if (attrs.showResults) {
const type = poll.get("type") === "number" ? "number" : "standard"; const type = poll.get("type") === "number" ? "number" : "standard";
return this.attach(`discourse-poll-${type}-results`, attrs); return this.attach(`discourse-poll-${type}-results`, attrs);
} } else if (options) {
const options = poll.get("options");
if (options) {
return h( return h(
"ul", "ul",
options.map(option => { options.map(option => {
@ -362,7 +351,7 @@ createWidget("discourse-poll-info", {
html(attrs) { html(attrs) {
const { poll } = attrs; const { poll } = attrs;
const count = poll.get("voters"); const count = poll.get("voters");
const result = [ const contents = [
h("p", [ h("p", [
h("span.info-number", count.toString()), h("span.info-number", count.toString()),
h("span.info-label", I18n.t("poll.voters", { count })) h("span.info-label", I18n.t("poll.voters", { count }))
@ -375,7 +364,7 @@ createWidget("discourse-poll-info", {
return total + parseInt(o.votes, 10); return total + parseInt(o.votes, 10);
}, 0); }, 0);
result.push( contents.push(
h("p", [ h("p", [
h("span.info-number", totalVotes.toString()), h("span.info-number", totalVotes.toString()),
h( h(
@ -391,37 +380,16 @@ createWidget("discourse-poll-info", {
poll.get("options.length") poll.get("options.length")
); );
if (help) { if (help) {
result.push( contents.push(infoTextHtml(help));
new RawHtml({ html: `<span class="info-text">${help}</span>` })
);
} }
} }
} }
if (!attrs.isClosed) { if (!attrs.isClosed && !attrs.showResults && poll.get("public")) {
if (!attrs.showResults && poll.get("public")) { contents.push(infoTextHtml(I18n.t("poll.public.title")));
result.push(h("span.info-text", I18n.t("poll.public.title")));
}
if (poll.close) {
const closeDate = moment.utc(poll.close);
if (closeDate.isValid()) {
const title = closeDate.format("LLL");
const timeLeft = moment().to(closeDate.local(), true);
result.push(
new RawHtml({
html: `<span class="info-text" title="${title}">${I18n.t(
"poll.automatic_close.closes_in",
{ timeLeft }
)}</span>`
})
);
}
}
} }
return result; return contents;
} }
}); });
@ -429,7 +397,7 @@ createWidget("discourse-poll-buttons", {
tagName: "div.poll-buttons", tagName: "div.poll-buttons",
html(attrs) { html(attrs) {
const results = []; const contents = [];
const { poll, post } = attrs; const { poll, post } = attrs;
const topicArchived = post.get("topic.archived"); const topicArchived = post.get("topic.archived");
const closed = attrs.isClosed; const closed = attrs.isClosed;
@ -437,7 +405,7 @@ createWidget("discourse-poll-buttons", {
if (attrs.isMultiple && !hideResultsDisabled) { if (attrs.isMultiple && !hideResultsDisabled) {
const castVotesDisabled = !attrs.canCastVotes; const castVotesDisabled = !attrs.canCastVotes;
results.push( contents.push(
this.attach("button", { this.attach("button", {
className: `btn cast-votes ${castVotesDisabled ? "" : "btn-primary"}`, className: `btn cast-votes ${castVotesDisabled ? "" : "btn-primary"}`,
label: "poll.cast-votes.label", label: "poll.cast-votes.label",
@ -446,11 +414,11 @@ createWidget("discourse-poll-buttons", {
action: "castVotes" action: "castVotes"
}) })
); );
results.push(" "); contents.push(" ");
} }
if (attrs.showResults || hideResultsDisabled) { if (attrs.showResults || hideResultsDisabled) {
results.push( contents.push(
this.attach("button", { this.attach("button", {
className: "btn toggle-results", className: "btn toggle-results",
label: "poll.hide-results.label", label: "poll.hide-results.label",
@ -461,31 +429,44 @@ createWidget("discourse-poll-buttons", {
}) })
); );
} else { } else {
results.push( if (poll.get("results") === "on_vote" && !attrs.hasVoted) {
this.attach("button", { contents.push(infoTextHtml(I18n.t("poll.results.vote.title")));
className: "btn toggle-results", } else if (poll.get("results") === "on_close" && !closed) {
label: "poll.show-results.label", contents.push(infoTextHtml(I18n.t("poll.results.closed.title")));
title: "poll.show-results.title", } else {
icon: "eye", contents.push(
disabled: poll.get("voters") === 0, this.attach("button", {
action: "toggleResults" className: "btn toggle-results",
}) label: "poll.show-results.label",
); title: "poll.show-results.title",
icon: "eye",
disabled: poll.get("voters") === 0,
action: "toggleResults"
})
);
}
} }
if (attrs.isAutomaticallyClosed) { if (poll.get("close")) {
const closeDate = moment.utc(poll.get("close")); const closeDate = moment.utc(poll.get("close"));
const title = closeDate.format("LLL"); if (closeDate.isValid()) {
const age = relativeAge(closeDate.toDate(), { addAgo: true }); const title = closeDate.format("LLL");
let label;
results.push( if (attrs.isAutomaticallyClosed) {
new RawHtml({ const age = relativeAge(closeDate.toDate(), { addAgo: true });
html: `<span class="info-text" title="${title}">${I18n.t( label = I18n.t("poll.automatic_close.age", { age });
"poll.automatic_close.age", } else {
{ age } const timeLeft = moment().to(closeDate.local(), true);
)}</span>` label = I18n.t("poll.automatic_close.closes_in", { timeLeft });
}) }
);
contents.push(
new RawHtml({
html: `<span class="info-text" title="${title}">${label}</span>`
})
);
}
} }
if ( if (
@ -496,7 +477,7 @@ createWidget("discourse-poll-buttons", {
) { ) {
if (closed) { if (closed) {
if (!attrs.isAutomaticallyClosed) { if (!attrs.isAutomaticallyClosed) {
results.push( contents.push(
this.attach("button", { this.attach("button", {
className: "btn toggle-status", className: "btn toggle-status",
label: "poll.open.label", label: "poll.open.label",
@ -507,7 +488,7 @@ createWidget("discourse-poll-buttons", {
); );
} }
} else { } else {
results.push( contents.push(
this.attach("button", { this.attach("button", {
className: "btn toggle-status btn-danger", className: "btn toggle-status btn-danger",
label: "poll.close.label", label: "poll.close.label",
@ -519,39 +500,49 @@ createWidget("discourse-poll-buttons", {
} }
} }
return results; return contents;
} }
}); });
export default createWidget("discourse-poll", { export default createWidget("discourse-poll", {
tagName: "div.poll", tagName: "div.poll",
buildKey: attrs => attrs.id, buildKey: attrs => `poll-${attrs.id}`,
buildAttributes(attrs) { buildAttributes(attrs) {
const { poll } = attrs;
return { return {
"data-poll-type": poll.get("type"), "data-poll-name": attrs.poll.get("name"),
"data-poll-name": poll.get("name"), "data-poll-type": attrs.poll.get("type")
"data-poll-status": poll.get("status"),
"data-poll-public": poll.get("public"),
"data-poll-close": poll.get("close")
}; };
}, },
defaultState(attrs) { defaultState(attrs) {
const showResults = this.isClosed() || attrs.post.get("topic.archived"); const { post, poll } = attrs;
const showResults = (
post.get("topic.archived") ||
this.isClosed() ||
(poll.get("results") !== "on_close" && this.hasVoted())
);
return { loading: false, showResults }; return { loading: false, showResults };
}, },
html(attrs, state) { html(attrs, state) {
const { showResults } = state; const showResults = (
state.showResults ||
attrs.post.get("topic.archived") ||
this.isClosed()
);
const newAttrs = jQuery.extend({}, attrs, { const newAttrs = jQuery.extend({}, attrs, {
showResults,
canCastVotes: this.canCastVotes(), canCastVotes: this.canCastVotes(),
isClosed: this.isClosed(), hasVoted: this.hasVoted(),
isAutomaticallyClosed: this.isAutomaticallyClosed(), isAutomaticallyClosed: this.isAutomaticallyClosed(),
isClosed: this.isClosed(),
isMultiple: this.isMultiple(),
max: this.max(),
min: this.min(), min: this.min(),
max: this.max() showResults,
}); });
return h("div", [ return h("div", [
@ -562,7 +553,7 @@ export default createWidget("discourse-poll", {
}, },
min() { min() {
let min = parseInt(this.attrs.poll.min, 10); let min = parseInt(this.attrs.poll.get("min"), 10);
if (isNaN(min) || min < 1) { if (isNaN(min) || min < 1) {
min = 1; min = 1;
} }
@ -570,8 +561,8 @@ export default createWidget("discourse-poll", {
}, },
max() { max() {
let max = parseInt(this.attrs.poll.max, 10); let max = parseInt(this.attrs.poll.get("max"), 10);
const numOptions = this.attrs.poll.options.length; const numOptions = this.attrs.poll.get("options.length");
if (isNaN(max) || max > numOptions) { if (isNaN(max) || max > numOptions) {
max = numOptions; max = numOptions;
} }
@ -588,6 +579,16 @@ export default createWidget("discourse-poll", {
return poll.get("status") === "closed" || this.isAutomaticallyClosed(); return poll.get("status") === "closed" || this.isAutomaticallyClosed();
}, },
isMultiple() {
const { poll } = this.attrs;
return poll.get("type") === "multiple";
},
hasVoted() {
const { vote } = this.attrs;
return vote && vote.length > 0;
},
canCastVotes() { canCastVotes() {
const { state, attrs } = this; const { state, attrs } = this;
@ -597,7 +598,7 @@ export default createWidget("discourse-poll", {
const selectedOptionCount = attrs.vote.length; const selectedOptionCount = attrs.vote.length;
if (attrs.isMultiple) { if (this.isMultiple()) {
return ( return (
selectedOptionCount >= this.min() && selectedOptionCount <= this.max() selectedOptionCount >= this.min() && selectedOptionCount <= this.max()
); );
@ -630,21 +631,21 @@ export default createWidget("discourse-poll", {
poll_name: poll.get("name"), poll_name: poll.get("name"),
status status
} }
}) }).then(() => {
.then(() => { poll.set("status", status);
poll.set("status", status); if (poll.get("results") === "on_close") {
this.scheduleRerender(); state.showResults = status === "closed";
}) }
.catch(error => { this.scheduleRerender();
if (error) { }).catch(error => {
popupAjaxError(error); if (error) {
} else { popupAjaxError(error);
bootbox.alert(I18n.t("poll.error_while_toggling_status")); } else {
} bootbox.alert(I18n.t("poll.error_while_toggling_status"));
}) }
.finally(() => { }).finally(() => {
state.loading = false; state.loading = false;
}); });
} }
} }
); );
@ -661,17 +662,13 @@ export default createWidget("discourse-poll", {
toggleOption(option) { toggleOption(option) {
const { attrs } = this; const { attrs } = this;
if (this.isClosed()) { if (this.isClosed()) return;
return; if (!this.currentUser) return this.showLogin();
}
if (!this.currentUser) {
this.showLogin();
}
const { vote } = attrs; const { vote } = attrs;
const chosenIdx = vote.indexOf(option.id); const chosenIdx = vote.indexOf(option.id);
if (!attrs.isMultiple) {
if (!this.isMultiple()) {
vote.length = 0; vote.length = 0;
} }
@ -681,18 +678,14 @@ export default createWidget("discourse-poll", {
vote.push(option.id); vote.push(option.id);
} }
if (!attrs.isMultiple) { if (!this.isMultiple()) {
return this.castVotes(); return this.castVotes();
} }
}, },
castVotes() { castVotes() {
if (!this.canCastVotes()) { if (!this.canCastVotes()) return;
return; if (!this.currentUser) return this.showLogin();
}
if (!this.currentUser) {
return this.showLogin();
}
const { attrs, state } = this; const { attrs, state } = this;
@ -702,22 +695,22 @@ export default createWidget("discourse-poll", {
type: "PUT", type: "PUT",
data: { data: {
post_id: attrs.post.id, post_id: attrs.post.id,
poll_name: attrs.poll.name, poll_name: attrs.poll.get("name"),
options: attrs.vote options: attrs.vote
} }
}) }).then(({ poll }) => {
.then(() => { attrs.poll.setProperties(poll);
if (attrs.poll.get("results") !== "on_close") {
state.showResults = true; state.showResults = true;
}) }
.catch(error => { }).catch(error => {
if (error) { if (error) {
popupAjaxError(error); popupAjaxError(error);
} else { } else {
bootbox.alert(I18n.t("poll.error_while_casting_votes")); bootbox.alert(I18n.t("poll.error_while_casting_votes"));
} }
}) }).finally(() => {
.finally(() => { state.loading = false;
state.loading = false; });
});
} }
}); });

View File

@ -8,12 +8,15 @@
.tip { .tip {
display: block; display: block;
min-height: 20px;
} }
.input-group-label { .input-group-label {
display: inline-block; display: inline-block;
width: 45px; min-width: 55px;
}
.poll-select {
line-height: 3em;
} }
.poll-number { .poll-number {

View File

@ -58,9 +58,6 @@ div.poll {
} }
.poll-buttons { .poll-buttons {
button {
float: none;
}
.info-text { .info-text {
margin: 0 5px; margin: 0 5px;
color: $primary-medium; color: $primary-medium;
@ -81,6 +78,7 @@ div.poll {
.results { .results {
> li { > li {
cursor: default;
padding: 0.5em 0.7em 0.7em 0.5em; padding: 0.5em 0.7em 0.7em 0.5em;
} }
.option { .option {

View File

@ -32,6 +32,10 @@ div.poll {
border-top: 1px solid $primary-low; border-top: 1px solid $primary-low;
padding: 1em 1.25em; padding: 1em 1.25em;
.info-text {
line-height: 2em;
}
.toggle-status { .toggle-status {
float: right; float: right;
} }

View File

@ -28,7 +28,13 @@ en:
average_rating: "Average rating: <strong>%{average}</strong>." average_rating: "Average rating: <strong>%{average}</strong>."
public: public:
title: "Votes are public." title: "Votes are <strong>public</strong>."
results:
vote:
title: "Results will be shown on <strong>vote</strong>."
closed:
title: "Results will be shown once <strong>closed</strong>."
multiple: multiple:
help: help:
@ -85,6 +91,11 @@ en:
regular: Single Choice regular: Single Choice
multiple: Multiple Choice multiple: Multiple Choice
number: Number Rating number: Number Rating
poll_result:
label: Results
always: Always visible
vote: On vote
closed: When closed
poll_config: poll_config:
max: Max max: Max
min: Min min: Min

View File

@ -43,14 +43,10 @@ en:
requires_at_least_1_valid_option: "You must select at least 1 valid option." 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 <strong>%{name}</strong> has votes and cannot be made public."
edit_window_expired: edit_window_expired:
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." cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes."
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." cannot_edit_named_poll_with_votes: "You cannot change the poll name <strong>${name}</strong> after the first %{minutes} minutes."
no_polls_associated_with_this_post: "No polls are associated with this post."
no_poll_with_this_name: "No poll named <strong>%{name}</strong> associated with this post." no_poll_with_this_name: "No poll named <strong>%{name}</strong> associated with this post."
post_is_deleted: "Cannot act on a deleted post." post_is_deleted: "Cannot act on a deleted post."

View File

@ -4,9 +4,12 @@ plugins:
client: true client: true
poll_maximum_options: poll_maximum_options:
default: 20 default: 20
min: 2
max: 100
client: true client: true
poll_edit_window_mins: poll_edit_window_mins:
default: 5 default: 5
min: 0
poll_minimum_trust_level_to_create: poll_minimum_trust_level_to_create:
default: 1 default: 1
client: true client: true

View File

@ -0,0 +1,39 @@
class CreatePollsTables < ActiveRecord::Migration[5.2]
def change
create_table :polls do |t|
t.references :post, index: true, foreign_key: true
t.string :name, null: false, default: "poll"
t.datetime :close_at
t.integer :type, null: false, default: 0
t.integer :status, null: false, default: 0
t.integer :results, null: false, default: 0
t.integer :visibility, null: false, default: 0
t.integer :min
t.integer :max
t.integer :step
t.integer :anonymous_voters
t.timestamps
end
add_index :polls, [:post_id, :name], unique: true
create_table :poll_options do |t|
t.references :poll, index: true, foreign_key: true
t.string :digest, null: false
t.text :html, null: false
t.integer :anonymous_votes
t.timestamps
end
add_index :poll_options, [:poll_id, :digest], unique: true
create_table :poll_votes, id: false do |t|
t.references :poll, foreign_key: true
t.references :poll_option, foreign_key: true
t.references :user, foreign_key: true
t.timestamps
end
add_index :poll_votes, [:poll_id, :poll_option_id, :user_id], unique: true
end
end

View File

@ -0,0 +1,159 @@
class MigratePollsData < ActiveRecord::Migration[5.2]
def escape(text)
PG::Connection.escape_string(text)
end
POLL_TYPES ||= {
"regular" => 0,
"multiple" => 1,
"number" => 2,
}
def up
# Ensure we don't have duplicate polls
DB.exec <<~SQL
WITH duplicates AS (
SELECT id, row_number() OVER (PARTITION BY post_id) r
FROM post_custom_fields
WHERE name = 'polls'
ORDER BY created_at
)
DELETE FROM post_custom_fields
WHERE id IN (SELECT id FROM duplicates WHERE r > 1)
SQL
# Ensure we don't have duplicate votes
DB.exec <<~SQL
WITH duplicates AS (
SELECT id, row_number() OVER (PARTITION BY post_id) r
FROM post_custom_fields
WHERE name = 'polls-votes'
ORDER BY created_at
)
DELETE FROM post_custom_fields
WHERE id IN (SELECT id FROM duplicates WHERE r > 1)
SQL
# Ensure we have votes records
DB.exec <<~SQL
INSERT INTO post_custom_fields (post_id, name, value, created_at, updated_at)
SELECT post_id, 'polls-votes', '{}', created_at, updated_at
FROM post_custom_fields
WHERE name = 'polls'
AND post_id NOT IN (SELECT post_id FROM post_custom_fields WHERE name = 'polls-votes')
SQL
sql = <<~SQL
SELECT polls.post_id
, polls.created_at
, polls.updated_at
, polls.value::json "polls"
, votes.value::json "votes"
FROM post_custom_fields polls
JOIN post_custom_fields votes
ON polls.post_id = votes.post_id
WHERE polls.name = 'polls'
AND votes.name = 'polls-votes'
ORDER BY polls.post_id
SQL
DB.query(sql).each do |r|
existing_user_ids = User.where(id: r.votes.keys).pluck(:id).to_set
# Poll votes are stored in a JSON object with the following hierarchy
# user_id -> poll_name -> options
# Since we're iterating over polls, we need to change the hierarchy to
# poll_name -> user_id -> options
votes = {}
r.votes.each do |user_id, user_votes|
# don't migrate votes from deleted/non-existing users
next unless existing_user_ids.include?(user_id.to_i)
user_votes.each do |poll_name, options|
votes[poll_name] ||= {}
votes[poll_name][user_id] = options
end
end
r.polls.values.each do |poll|
name = poll["name"].presence || "poll"
type = POLL_TYPES[(poll["type"].presence || "")[/(regular|multiple|number)/, 1] || "regular"]
status = poll["status"] == "open" ? 0 : 1
visibility = poll["public"] == "true" ? 1 : 0
close_at = (Time.zone.parse(poll["close"]) rescue nil)
min = poll["min"].to_i
max = poll["max"].to_i
step = poll["step"].to_i
anonymous_voters = poll["anonymous_voters"].to_i
poll_id = execute(<<~SQL
INSERT INTO polls (
post_id,
name,
type,
status,
visibility,
close_at,
min,
max,
step,
anonymous_voters,
created_at,
updated_at
) VALUES (
#{r.post_id},
'#{escape(name)}',
#{type},
#{status},
#{visibility},
#{close_at ? "'#{close_at}'" : "NULL"},
#{min > 0 ? min : "NULL"},
#{max > min ? max : "NULL"},
#{step > 0 ? step : "NULL"},
#{anonymous_voters > 0 ? anonymous_voters : "NULL"},
'#{r.created_at}',
'#{r.updated_at}'
) RETURNING id
SQL
)[0]["id"]
option_ids = Hash[*DB.query_single(<<~SQL
INSERT INTO poll_options
(poll_id, digest, html, anonymous_votes, created_at, updated_at)
VALUES
#{poll["options"].map { |option|
"(#{poll_id}, '#{escape(option["id"])}', '#{escape(option["html"].strip)}', #{option["anonymous_votes"].to_i}, '#{r.created_at}', '#{r.updated_at}')" }.join(",")
}
RETURNING digest, id
SQL
)]
if votes[name].present?
poll_votes = votes[name].map do |user_id, options|
options
.select { |o| option_ids.has_key?(o) }
.map { |o| "(#{poll_id}, #{option_ids[o]}, #{user_id.to_i}, '#{r.created_at}', '#{r.updated_at}')" }
end.flatten
if poll_votes.present?
execute <<~SQL
INSERT INTO poll_votes (poll_id, poll_option_id, user_id, created_at, updated_at)
VALUES #{poll_votes.join(",")}
SQL
end
end
end
end
execute <<~SQL
INSERT INTO post_custom_fields (name, value, post_id, created_at, updated_at)
SELECT 'has_polls', 't', post_id, MIN(created_at), MIN(updated_at)
FROM polls
GROUP BY post_id
SQL
end
def down
end
end

View File

@ -1,132 +1,115 @@
# frozen_string_literal: true
module DiscoursePoll module DiscoursePoll
class PollsUpdater class PollsUpdater
VALID_POLLS_CONFIGS = %w{type min max public close}.map(&:freeze)
POLL_ATTRIBUTES ||= %w{close_at max min results status step type visibility}
def self.update(post, polls) def self.update(post, polls)
# load previous polls ::Poll.transaction do
previous_polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] || {} has_changed = false
edit_window = SiteSetting.poll_edit_window_mins
# extract options old_poll_names = ::Poll.where(post: post).pluck(:name)
current_option_ids = extract_option_ids(polls) new_poll_names = polls.keys
previous_option_ids = extract_option_ids(previous_polls)
# are the polls different? deleted_poll_names = old_poll_names - new_poll_names
if polls_updated?(polls, previous_polls) || (current_option_ids != previous_option_ids) created_poll_names = new_poll_names - old_poll_names
has_votes = total_votes(previous_polls) > 0
# outside of the edit window? # delete polls
poll_edit_window_mins = SiteSetting.poll_edit_window_mins if deleted_poll_names.present?
::Poll.where(post: post, name: deleted_poll_names).destroy_all
end
if post.created_at < poll_edit_window_mins.minutes.ago && has_votes # create polls
# deal with option changes if created_poll_names.present?
if User.staff.where(id: post.last_editor_id).exists? has_changed = true
# staff can edit options polls.slice(*created_poll_names).values.each do |poll|
polls.each_key do |poll_name| Poll.create!(post.id, poll)
if polls.dig(poll_name, "options")&.size != previous_polls.dig(poll_name, "options")&.size && previous_polls.dig(poll_name, "voters").to_i > 0 end
post.errors.add(:base, I18n.t("poll.edit_window_expired.staff_cannot_add_or_remove_options", minutes: poll_edit_window_mins)) end
# update polls
::Poll.includes(:poll_votes, :poll_options).where(post: post).find_each do |old_poll|
new_poll = polls[old_poll.name]
new_poll_options = new_poll["options"]
attributes = new_poll.slice(*POLL_ATTRIBUTES)
attributes["visibility"] = new_poll["public"] == "true" ? "everyone" : "secret"
attributes["close_at"] = Time.zone.parse(new_poll["close"]) rescue nil
poll = ::Poll.new(attributes)
if is_different?(old_poll, poll, new_poll_options)
# only prevent changes when there's at least 1 vote
if old_poll.poll_votes.size > 0
# can't change after edit window (when enabled)
if edit_window > 0 && old_poll.created_at < edit_window.minutes.ago
error = poll.name == DiscoursePoll::DEFAULT_POLL_NAME ?
I18n.t("poll.edit_window_expired.cannot_edit_default_poll_with_votes", minutes: edit_window) :
I18n.t("poll.edit_window_expired.cannot_edit_named_poll_with_votes", minutes: edit_window, name: poll.name)
post.errors.add(:base, error)
return return
end end
end end
else
# OP cannot edit poll options # update poll
post.errors.add(:base, I18n.t("poll.edit_window_expired.op_cannot_edit_options", minutes: poll_edit_window_mins)) POLL_ATTRIBUTES.each do |attr|
return old_poll.send("#{attr}=", poll.send(attr))
end
old_poll.save!
# keep track of anonymous votes
anonymous_votes = old_poll.poll_options.map { |pv| [pv.digest, pv.anonymous_votes] }.to_h
# destroy existing options & votes
::PollOption.where(poll: old_poll).destroy_all
# create new options
new_poll_options.each do |option|
::PollOption.create!(
poll: old_poll,
digest: option["id"],
html: option["html"].strip,
anonymous_votes: anonymous_votes[option["id"]],
)
end
has_changed = true
end end
end end
# try to merge votes if ::Poll.exists?(post: post)
polls.each_key do |poll_name| post.custom_fields[HAS_POLLS] = true
next unless previous_polls.has_key?(poll_name) else
return if has_votes && private_to_public_poll?(post, previous_polls, polls, poll_name) post.custom_fields.delete(HAS_POLLS)
# 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 end
# immediately store the polls
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true) post.save_custom_fields(true)
# re-schedule jobs if has_changed
DiscoursePoll::Poll.schedule_jobs(post) polls = ::Poll.includes(poll_options: :poll_votes).where(post: post)
polls = ActiveModel::ArraySerializer.new(polls, each_serializer: PollSerializer, root: false).as_json
# publish the changes MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
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
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"].to_i }.sum
end end
private private
def self.private_to_public_poll?(post, previous_polls, current_polls, poll_name) def self.is_different?(old_poll, new_poll, new_options)
previous_poll = previous_polls[poll_name] # an attribute was changed?
current_poll = current_polls[poll_name] POLL_ATTRIBUTES.each do |attr|
return true if old_poll.send(attr) != new_poll.send(attr)
if previous_poll["public"].nil? && current_poll["public"] == "true"
error = poll_name == DiscoursePoll::DEFAULT_POLL_NAME ?
I18n.t("poll.default_cannot_be_made_public") :
I18n.t("poll.named_cannot_be_made_public", name: poll_name)
post.errors.add(:base, error)
return true
end end
# an option was changed?
return true if old_poll.poll_options.map { |o| o.digest }.sort != new_options.map { |o| o["id"] }.sort
# it's the same!
false false
end end
end end
end end

View File

@ -8,22 +8,11 @@ module DiscoursePoll
polls = {} polls = {}
DiscoursePoll::Poll::extract(@post.raw, @post.topic_id, @post.user_id).each do |poll| DiscoursePoll::Poll::extract(@post.raw, @post.topic_id, @post.user_id).each do |poll|
# polls should have a unique name
return false unless unique_poll_name?(polls, poll) return false unless unique_poll_name?(polls, poll)
# options must be unique
return false unless unique_options?(poll) return false unless unique_options?(poll)
# at least 2 options
return false unless at_least_two_options?(poll) return false unless at_least_two_options?(poll)
# maximum # of options
return false unless valid_number_of_options?(poll) return false unless valid_number_of_options?(poll)
# poll with multiple choices
return false unless valid_multiple_choice_settings?(poll) return false unless valid_multiple_choice_settings?(poll)
# store the valid poll
polls[poll["name"]] = poll polls[poll["name"]] = poll
end end
@ -90,11 +79,11 @@ module DiscoursePoll
def valid_multiple_choice_settings?(poll) def valid_multiple_choice_settings?(poll)
if poll["type"] == "multiple" if poll["type"] == "multiple"
num_of_options = poll["options"].size options = poll["options"].size
min = (poll["min"].presence || 1).to_i min = (poll["min"].presence || 1).to_i
max = (poll["max"].presence || num_of_options).to_i max = (poll["max"].presence || options).to_i
if min > max || min <= 0 || max <= 0 || max > num_of_options || min >= num_of_options if min > max || min <= 0 || max <= 0 || max > options || min >= options
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) @post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
else else

View File

@ -1,59 +0,0 @@
module DiscoursePoll
class VotesUpdater
def self.merge_users!(source_user, target_user)
post_ids = PostCustomField.where(name: DiscoursePoll::VOTES_CUSTOM_FIELD)
.where("value :: JSON -> ? IS NOT NULL", source_user.id.to_s)
.pluck(:post_id)
post_ids.each do |post_id|
DistributedMutex.synchronize("discourse_poll-#{post_id}") do
post = Post.find_by(id: post_id)
update_votes(post, source_user, target_user) if post
end
end
end
def self.update_votes(post, source_user, target_user)
polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
votes = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
return if polls.nil? || votes.nil? || !votes.has_key?(source_user.id.to_s)
if votes.has_key?(target_user.id.to_s)
remove_votes(polls, votes, source_user)
else
replace_voter_id(polls, votes, source_user, target_user)
end
post.save_custom_fields(true)
end
def self.remove_votes(polls, votes, source_user)
votes.delete(source_user.id.to_s).each do |poll_name, option_ids|
poll = polls[poll_name]
next unless poll && option_ids
poll["options"].each do |option|
if option_ids.include?(option["id"])
option["votes"] -= 1
voter_ids = option["voter_ids"]
voter_ids.delete(source_user.id) if voter_ids
end
end
end
end
def self.replace_voter_id(polls, votes, source_user, target_user)
votes[target_user.id.to_s] = votes.delete(source_user.id.to_s)
polls.each_value do |poll|
next unless poll["public"] == "true"
poll["options"].each do |option|
voter_ids = option["voter_ids"]
voter_ids << target_user.id if voter_ids&.delete(source_user.id)
end
end
end
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
# name: poll # name: poll
# about: Official poll plugin for Discourse # about: Official poll plugin for Discourse
# version: 0.9 # version: 1.0
# authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip) # authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip)
# url: https://github.com/discourse/discourse/tree/master/plugins/poll # url: https://github.com/discourse/discourse/tree/master/plugins/poll
@ -12,22 +14,26 @@ register_asset "stylesheets/mobile/poll.scss", :mobile
enabled_site_setting :poll_enabled enabled_site_setting :poll_enabled
hide_plugin if self.respond_to?(:hide_plugin) hide_plugin if self.respond_to?(:hide_plugin)
PLUGIN_NAME ||= "discourse_poll".freeze PLUGIN_NAME ||= "discourse_poll"
DATA_PREFIX ||= "data-poll-".freeze DATA_PREFIX ||= "data-poll-"
after_initialize do after_initialize do
require File.expand_path("../jobs/regular/close_poll", __FILE__) [
"../app/models/poll_vote",
"../app/models/poll_option",
"../app/models/poll",
"../app/serializers/poll_option_serializer",
"../app/serializers/poll_serializer",
"../lib/polls_validator",
"../lib/polls_updater",
"../lib/post_validator",
"../jobs/regular/close_poll",
].each { |path| require File.expand_path(path, __FILE__) }
module ::DiscoursePoll module ::DiscoursePoll
DEFAULT_POLL_NAME ||= "poll".freeze HAS_POLLS ||= "has_polls"
POLLS_CUSTOM_FIELD ||= "polls".freeze DEFAULT_POLL_NAME ||= "poll"
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"
autoload :VotesUpdater, "#{Rails.root}/plugins/poll/lib/votes_updater"
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
engine_name PLUGIN_NAME engine_name PLUGIN_NAME
@ -39,8 +45,7 @@ after_initialize do
class << self class << self
def vote(post_id, poll_name, options, user) def vote(post_id, poll_name, options, user)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do Poll.transaction do
user_id = user.id
post = Post.find_by(id: post_id) post = Post.find_by(id: post_id)
# post must not be deleted # post must not be deleted
@ -49,85 +54,60 @@ after_initialize do
end end
# topic must not be archived # topic must not be archived
if post.topic.try(:archived) if post.topic&.archived
raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote") raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote")
end end
# user must be allowed to post in topic # user must be allowed to post in topic
unless Guardian.new(user).can_create_post?(post.topic) if !Guardian.new(user).can_create_post?(post.topic)
raise StandardError.new I18n.t("poll.user_cant_post_in_topic") raise StandardError.new I18n.t("poll.user_cant_post_in_topic")
end end
polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] poll = Poll.includes(poll_options: :poll_votes).find_by(post_id: post_id, name: poll_name)
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) unless poll
raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll.is_closed?
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"
# ensure no race condition when poll is automatically closed
if poll["close"].present?
close_date =
begin
close_date = Time.zone.parse(poll["close"])
rescue ArgumentError
end
raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if close_date && close_date <= Time.zone.now
end
# remove options that aren't available in the poll # remove options that aren't available in the poll
available_options = poll["options"].map { |o| o["id"] }.to_set available_options = poll.poll_options.map { |o| o.digest }.to_set
options.select! { |o| available_options.include?(o) } options.select! { |o| available_options.include?(o) }
raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty? raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
poll["voters"] = poll["anonymous_voters"] || 0 new_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
all_options = Hash.new(0) obj << option.id if options.include?(option.digest)
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] = options
post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].each do |_, user_votes|
next unless votes = user_votes[poll_name]
votes.each { |option| all_options[option] += 1 }
poll["voters"] += 1 if (available_options & votes.to_set).size > 0
end end
public_poll = (poll["public"] == "true") # remove non-selected votes
PollVote
.where(poll: poll, user: user)
.where.not(poll_option_id: new_option_ids)
.delete_all
poll["options"].each do |option| old_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
anonymous_votes = option["anonymous_votes"] || 0 if option.poll_votes.any? { |v| v.user_id == user.id }
option["votes"] = all_options[option["id"]] + anonymous_votes obj << 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
end end
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls # create missing votes
post.save_custom_fields(true) (new_option_ids - old_option_ids).each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
payload = { post_id: post_id, polls: polls } poll.reload
payload.merge!(user: UserNameSerializer.new(user).serializable_hash) if public_poll
serialized_poll = PollSerializer.new(poll, root: false).as_json
payload = { post_id: post_id, polls: [serialized_poll] }
MessageBus.publish("/polls/#{post.topic_id}", payload) MessageBus.publish("/polls/#{post.topic_id}", payload)
[poll, options] [serialized_poll, options]
end end
end end
def toggle_status(post_id, poll_name, status, user_id) def toggle_status(post_id, poll_name, status, user)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do Poll.transaction do
post = Post.find_by(id: post_id) post = Post.find_by(id: post_id)
# post must not be deleted # post must not be deleted
@ -136,46 +116,143 @@ after_initialize do
end end
# topic must not be archived # topic must not be archived
if post.topic.try(:archived) if post.topic&.archived
raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status") raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status")
end end
user = User.find_by(id: user_id)
# either staff member or OP # either staff member or OP
unless user_id == post.user_id || user.try(:staff?) unless post.user_id == user&.id || user&.staff?
raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status") raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status")
end end
polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] poll = Poll.find_by(post_id: post_id, name: poll_name)
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) unless poll
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank?
polls[poll_name]["status"] = status poll.status = status
poll.save!
post.save_custom_fields(true) serialized_poll = PollSerializer.new(poll, root: false).as_json
payload = { post_id: post_id, polls: [serialized_poll] }
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls) MessageBus.publish("/polls/#{post.topic_id}", payload)
polls[poll_name] serialized_poll
end end
end end
def schedule_jobs(post) def voters(post_id, poll_name, user, opts = {})
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].each do |name, poll| post = Post.find_by(id: post_id)
Jobs.cancel_scheduled_job(:close_poll, post_id: post.id, poll_name: name) raise Discourse::InvalidParameters.new("post_id is invalid") unless post
if poll["status"] == "open" && poll["close"].present? poll = Poll.find_by(post_id: post_id, name: poll_name)
close_date = raise Discourse::InvalidParameters.new("poll_name is invalid") unless poll&.can_see_voters?(user)
begin
Time.zone.parse(poll["close"])
rescue ArgumentError
end
Jobs.enqueue_at(close_date, :close_poll, post_id: post.id, poll_name: name) if close_date && close_date > Time.zone.now limit = (opts["limit"] || 25).to_i
limit = 0 if limit < 0
limit = 50 if limit > 50
page = (opts["page"] || 1).to_i
page = 1 if page < 1
offset = (page - 1) * limit
option_digest = opts["option_id"].to_s
if poll.number?
user_ids = PollVote
.where(poll: poll)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
result = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
elsif option_digest.present?
poll_option = PollOption.find_by(poll: poll, digest: option_digest)
raise Discourse::InvalidParameters.new("option_id is invalid") unless poll_option
user_ids = PollVote
.where(poll: poll, poll_option: poll_option)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
user_hashes = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
result = { option_digest => user_hashes }
else
votes = DB.query <<~SQL
SELECT digest, user_id
FROM (
SELECT digest
, user_id
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
FROM poll_votes pv
JOIN poll_options po ON pv.poll_option_id = po.id
WHERE pv.poll_id = #{poll.id}
AND po.poll_id = #{poll.id}
) v
WHERE row BETWEEN #{offset} AND #{offset + limit}
SQL
user_ids = votes.map { |v| v.user_id }.to_set
user_hashes = User
.where(id: user_ids)
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
.to_h
result = {}
votes.each do |v|
result[v.digest] ||= []
result[v.digest] << user_hashes[v.user_id]
end end
end end
result
end
def schedule_jobs(post)
Poll.where(post: post).find_each do |poll|
Jobs.cancel_scheduled_job(:close_poll, poll_id: poll.id)
if poll.open? && poll.close_at && poll.close_at > Time.zone.now
Jobs.enqueue_at(poll.close_at, :close_poll, poll_id: poll.id)
end
end
end
def create!(post_id, poll)
close_at = begin
Time.zone.parse(poll["close"] || '')
rescue ArgumentError
end
created_poll = Poll.create!(
post_id: post_id,
name: poll["name"].presence || "poll",
close_at: close_at,
type: poll["type"].presence || "regular",
status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret",
results: poll["results"].presence || "always",
min: poll["min"],
max: poll["max"],
step: poll["step"]
)
poll["options"].each do |option|
PollOption.create!(
poll: created_poll,
digest: option["id"].presence,
html: option["html"].presence.strip
)
end
end end
def extract(raw, topic_id, user_id = nil) def extract(raw, topic_id, user_id = nil)
@ -184,7 +261,7 @@ after_initialize do
cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id) cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id)
Nokogiri::HTML(cooked).css("div.poll").map do |p| Nokogiri::HTML(cooked).css("div.poll").map do |p|
poll = { "options" => [], "voters" => 0, "name" => DiscoursePoll::DEFAULT_POLL_NAME } poll = { "options" => [], "name" => DiscoursePoll::DEFAULT_POLL_NAME }
# attributes # attributes
p.attributes.values.each do |attribute| p.attributes.values.each do |attribute|
@ -195,8 +272,8 @@ after_initialize do
# options # options
p.css("li[#{DATA_PREFIX}option-id]").each do |o| p.css("li[#{DATA_PREFIX}option-id]").each do |o|
option_id = o.attributes[DATA_PREFIX + "option-id"].value || "" option_id = o.attributes[DATA_PREFIX + "option-id"].value.to_s
poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 } poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end end
poll poll
@ -229,10 +306,9 @@ after_initialize do
post_id = params.require(:post_id) post_id = params.require(:post_id)
poll_name = params.require(:poll_name) poll_name = params.require(:poll_name)
status = params.require(:status) status = params.require(:status)
user_id = current_user.id
begin begin
poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, user_id) poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, current_user)
render json: { poll: poll } render json: { poll: poll }
rescue StandardError => e rescue StandardError => e
render_json_error e.message render_json_error e.message
@ -240,70 +316,16 @@ after_initialize do
end end
def voters def voters
post_id = params.require(:post_id) post_id = params.require(:post_id)
poll_name = params.require(:poll_name) poll_name = params.require(:poll_name)
post = Post.find_by(id: post_id) opts = params.permit(:limit, :page, :option_id)
raise Discourse::InvalidParameters.new("post_id is invalid") if !post
raise Discourse::InvalidParameters.new("no poll exists for this post_id") unless post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
poll = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD][poll_name] begin
raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll render json: { voters: DiscoursePoll::Poll.voters(post_id, poll_name, current_user, opts) }
rescue StandardError => e
voter_limit = (params[:voter_limit] || 25).to_i render_json_error e.message
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).each do |user|
user_hash = UserNameSerializer.new(user).serializable_hash
# protect against poorly denormalized data
poll_votes&.dig(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 { |user| UserNameSerializer.new(user).serializable_hash }
end end
render json: { poll_name => result }
end end
end end
@ -318,40 +340,41 @@ after_initialize do
end end
Post.class_eval do Post.class_eval do
attr_accessor :polls attr_accessor :extracted_polls
has_many :polls, dependent: :destroy
after_save do after_save do
next if self.polls.blank? || !self.polls.is_a?(Hash) polls = self.extracted_polls
next if polls.blank? || !polls.is_a?(Hash)
post = self post = self
polls = self.polls
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do Poll.transaction do
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls polls.values.each do |poll|
DiscoursePoll::Poll.create!(post.id, poll)
end
post.custom_fields[DiscoursePoll::HAS_POLLS] = true
post.save_custom_fields(true) post.save_custom_fields(true)
end end
end end
end end
validate(:post, :validate_polls) do |force = nil| validate(:post, :validate_polls) do |force = nil|
# only care when raw has changed!
return unless self.raw_changed? || force return unless self.raw_changed? || force
validator = DiscoursePoll::PollsValidator.new(self) validator = DiscoursePoll::PollsValidator.new(self)
return unless (polls = validator.validate_polls) return unless (polls = validator.validate_polls)
if !polls.empty? if polls.present?
validator = DiscoursePoll::PostValidator.new(self) validator = DiscoursePoll::PostValidator.new(self)
return unless validator.validate_post return unless validator.validate_post
end end
# are we updating a post? # are we updating a post?
if self.id.present? if self.id.present?
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{self.id}") do DiscoursePoll::PollsUpdater.update(self, polls)
DiscoursePoll::PollsUpdater.update(self, polls)
end
else else
self.polls = polls self.extracted_polls = polls
end end
true true
@ -380,13 +403,6 @@ after_initialize do
end end
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| on(:reduce_cooked) do |fragment, post|
if post.nil? || post.trashed? if post.nil? || post.trashed?
fragment.css(".poll, [data-poll-name]").each(&:remove) fragment.css(".poll, [data-poll-name]").each(&:remove)
@ -399,38 +415,83 @@ after_initialize do
end end
on(:post_created) do |post| on(:post_created) do |post|
next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank?
# signals the front-end we have polls for that post
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD])
# schedule automatic close jobs
DiscoursePoll::Poll.schedule_jobs(post) DiscoursePoll::Poll.schedule_jobs(post)
end
on(:merging_users) do |source_user, target_user| unless post.is_first_post?
DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user) polls = ActiveModel::ArraySerializer.new(post.polls, each_serializer: PollSerializer, root: false).as_json
end MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
add_to_serializer(:post, :polls, false) do
polls = post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].dup
polls.each do |_, poll|
next if !poll
poll["options"].each do |option|
option.delete("voter_ids")
end
end end
end end
add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? } on(:merging_users) do |source_user, target_user|
PollVote.where(user_id: source_user.id).update_all(user_id: target_user.id)
end
on(:user_destroyed) do |user|
PollVote.where(user_id: user.id).delete_all
end
register_post_custom_field_type(DiscoursePoll::HAS_POLLS, :boolean)
topic_view_post_custom_fields_whitelister { [DiscoursePoll::HAS_POLLS] }
add_to_class(:topic_view, :polls) do
@polls ||= begin
polls = {}
post_with_polls = @post_custom_fields.each_with_object([]) do |fields, obj|
obj << fields[0] if fields[1][DiscoursePoll::HAS_POLLS]
end
if post_with_polls.present?
Poll
.includes(poll_options: :poll_votes, poll_votes: :poll_option)
.where(post_id: post_with_polls)
.each do |p|
polls[p.post_id] ||= []
polls[p.post_id] << p
end
end
polls
end
end
add_to_serializer(:post, :preloaded_polls, false) do
@preloaded_polls ||= if @topic_view.present?
@topic_view.polls[object.id]
else
Poll.includes(poll_options: :poll_votes).where(post: object)
end
end
add_to_serializer(:post, :include_preloaded_polls?) do
false
end
add_to_serializer(:post, :polls, false) do
preloaded_polls.map { |p| PollSerializer.new(p, root: false) }
end
add_to_serializer(:post, :include_polls?) do
preloaded_polls.present?
end
add_to_serializer(:post, :polls_votes, false) do add_to_serializer(:post, :polls_votes, false) do
post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"] preloaded_polls.map do |poll|
user_poll_votes = poll.poll_votes.each_with_object([]) do |vote, obj|
if vote.user_id == scope.user.id
obj << vote.poll_option.digest
end
end
[poll.name, user_poll_votes]
end.to_h
end end
add_to_serializer(:post, :include_polls_votes?) do add_to_serializer(:post, :include_polls_votes?) do
return unless scope.user scope.user&.id.present? &&
return unless post_custom_fields.present? preloaded_polls.present? &&
return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? preloaded_polls.any? { |p| p.has_voted?(scope.user) }
post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}")
end end
end end

View File

@ -1,5 +1,4 @@
require "rails_helper" require "rails_helper"
require_relative "../helpers"
describe ::DiscoursePoll::PollsController do describe ::DiscoursePoll::PollsController do
routes { ::DiscoursePoll::Engine.routes } routes { ::DiscoursePoll::Engine.routes }
@ -8,6 +7,8 @@ describe ::DiscoursePoll::PollsController do
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let(:poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll]\n- A\n- B\n[/poll]") } let(:poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll]\n- A\n- B\n[/poll]") }
let(:multi_poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll min=1 max=2 type=multiple public=true]\n- A\n- B\n[/poll]") } let(:multi_poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll min=1 max=2 type=multiple public=true]\n- A\n- B\n[/poll]") }
let(:public_poll_on_vote) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_vote]\n- A\n- B\n[/poll]") }
let(:public_poll_on_close) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_close]\n- A\n- B\n[/poll]") }
describe "#vote" do describe "#vote" do
@ -56,7 +57,7 @@ describe ::DiscoursePoll::PollsController do
expect(json["poll"]["options"][1]["votes"]).to eq(1) expect(json["poll"]["options"][1]["votes"]).to eq(1)
end end
it "works even if topic is closed" do it "works on closed topics" do
topic.update_attribute(:closed, true) topic.update_attribute(:closed, true)
put :vote, params: { put :vote, params: {
@ -102,16 +103,6 @@ describe ::DiscoursePoll::PollsController do
expect(json["errors"][0]).to eq(I18n.t("poll.user_cant_post_in_topic")) expect(json["errors"][0]).to eq(I18n.t("poll.user_cant_post_in_topic"))
end end
it "ensures polls are associated with the post" do
put :vote, params: {
post_id: Fabricate(:post).id, poll_name: "foobar", options: ["A"]
}, format: :json
expect(response.status).not_to eq(200)
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.no_polls_associated_with_this_post"))
end
it "checks the name of the poll" do it "checks the name of the poll" do
put :vote, params: { put :vote, params: {
post_id: poll.id, poll_name: "foobar", options: ["A"] post_id: poll.id, poll_name: "foobar", options: ["A"]
@ -135,8 +126,10 @@ describe ::DiscoursePoll::PollsController do
end end
it "doesn't discard anonymous votes when someone votes" do it "doesn't discard anonymous votes when someone votes" do
default_poll = poll.custom_fields["polls"]["poll"] the_poll = poll.polls.first
add_anonymous_votes(poll, default_poll, 17, "5c24fc1df56d764b550ceae1b9319125" => 11, "e89dec30bbd9bf50fabf6a05b4324edf" => 6) the_poll.update_attribute(:anonymous_voters, 17)
the_poll.poll_options[0].update_attribute(:anonymous_votes, 11)
the_poll.poll_options[1].update_attribute(:anonymous_votes, 6)
put :vote, params: { put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"] post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
@ -149,57 +142,6 @@ describe ::DiscoursePoll::PollsController do
expect(json["poll"]["options"][0]["votes"]).to eq(12) expect(json["poll"]["options"][0]["votes"]).to eq(12)
expect(json["poll"]["options"][1]["votes"]).to eq(6) expect(json["poll"]["options"][1]["votes"]).to eq(6)
end end
it "tracks the users ids for public polls" do
public_poll = Fabricate(:post, topic_id: topic.id, user_id: user.id, raw: "[poll public=true]\n- A\n- B\n[/poll]")
body = { post_id: public_poll.id, poll_name: "poll" }
message = MessageBus.track_publish do
put :vote,
params: body.merge(options: ["5c24fc1df56d764b550ceae1b9319125"]),
format: :json
end.first
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["poll"]["voters"]).to eq(1)
expect(json["poll"]["options"][0]["votes"]).to eq(1)
expect(json["poll"]["options"][1]["votes"]).to eq(0)
expect(json["poll"]["options"][0]["voter_ids"]).to eq([user.id])
expect(json["poll"]["options"][1]["voter_ids"]).to eq([])
expect(message.data[:post_id].to_i).to eq(public_poll.id)
expect(message.data[:user][:id].to_i).to eq(user.id)
put :vote,
params: body.merge(options: ["e89dec30bbd9bf50fabf6a05b4324edf"]),
format: :json
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["poll"]["voters"]).to eq(1)
expect(json["poll"]["options"][0]["votes"]).to eq(0)
expect(json["poll"]["options"][1]["votes"]).to eq(1)
expect(json["poll"]["options"][0]["voter_ids"]).to eq([])
expect(json["poll"]["options"][1]["voter_ids"]).to eq([user.id])
another_user = Fabricate(:user)
log_in_user(another_user)
put :vote,
params: body.merge(options: ["e89dec30bbd9bf50fabf6a05b4324edf", "5c24fc1df56d764b550ceae1b9319125"]),
format: :json
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["poll"]["voters"]).to eq(2)
expect(json["poll"]["options"][0]["votes"]).to eq(1)
expect(json["poll"]["options"][1]["votes"]).to eq(2)
expect(json["poll"]["options"][0]["voter_ids"]).to eq([another_user.id])
expect(json["poll"]["options"][1]["voter_ids"]).to eq([user.id, another_user.id])
end
end end
describe "#toggle_status" do describe "#toggle_status" do
@ -248,13 +190,12 @@ describe ::DiscoursePoll::PollsController do
end end
describe "votes" do describe "#voters" do
let(:first) { "5c24fc1df56d764b550ceae1b9319125" }
let(:second) { "e89dec30bbd9bf50fabf6a05b4324edf" }
it "correctly handles offset" do it "correctly handles offset" do
first = "5c24fc1df56d764b550ceae1b9319125"
second = "e89dec30bbd9bf50fabf6a05b4324edf"
user1 = log_in user1 = log_in
put :vote, params: { put :vote, params: {
@ -274,15 +215,13 @@ describe ::DiscoursePoll::PollsController do
user3 = log_in user3 = log_in
put :vote, params: { put :vote, params: {
post_id: multi_poll.id, post_id: multi_poll.id, poll_name: "poll", options: [first, second]
poll_name: "poll",
options: [first, second]
}, format: :json }, format: :json
expect(response.status).to eq(200) expect(response.status).to eq(200)
get :voters, params: { get :voters, params: {
poll_name: 'poll', post_id: multi_poll.id, voter_limit: 2 poll_name: 'poll', post_id: multi_poll.id, limit: 2
}, format: :json }, format: :json
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -290,25 +229,81 @@ describe ::DiscoursePoll::PollsController do
json = JSON.parse(response.body) json = JSON.parse(response.body)
# no user3 cause voter_limit is 2 # no user3 cause voter_limit is 2
expect(json["poll"][first].map { |h| h["id"] }.sort).to eq([user1.id, user2.id]) expect(json["voters"][first].map { |h| h["id"] }).to contain_exactly(user1.id, user2.id)
expect(json["poll"][second].map { |h| h["id"] }).to eq([user3.id]) expect(json["voters"][second].map { |h| h["id"] }).to contain_exactly(user3.id)
end
reloaded = Post.find(multi_poll.id) it "ensures voters can only be seen after casting a vote" do
put :vote, params: {
# break the custom poll and make sure we still return something sane here post_id: public_poll_on_vote.id, poll_name: "poll", options: [first]
# TODO: normalize this data so we don't store the information twice and there is a chance
# that somehow a bg job can cause both fields to be out-of-sync
poll_votes = reloaded.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
poll_votes.delete user2.id.to_s
reloaded.save_custom_fields(true)
get :voters, params: {
poll_name: 'poll', post_id: multi_poll.id, voter_limit: 2
}, format: :json }, format: :json
expect(response.status).to eq(200) expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
expect(response.status).to eq(200)
json = JSON.parse(response.body)
expect(json["voters"][first].size).to eq(1)
user2 = log_in
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
expect(response.status).to eq(422)
put :vote, params: {
post_id: public_poll_on_vote.id, poll_name: "poll", options: [second]
}, format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
expect(response.status).to eq(200)
json = JSON.parse(response.body)
expect(json["voters"][first].size).to eq(1)
expect(json["voters"][second].size).to eq(1)
end
it "ensures voters can only be seen when poll is closed" do
put :vote, params: {
post_id: public_poll_on_close.id, poll_name: "poll", options: [first]
}, format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_close.id
}, format: :json
expect(response.status).to eq(422)
put :toggle_status, params: {
post_id: public_poll_on_close.id, poll_name: "poll", status: "closed"
}, format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_close.id
}, format: :json
expect(response.status).to eq(200)
json = JSON.parse(response.body)
expect(json["voters"][first].size).to eq(1)
end end
end end

View File

@ -1,5 +1,4 @@
require "rails_helper" require "rails_helper"
require_relative "../helpers"
describe PostsController do describe PostsController do
let!(:user) { log_in } let!(:user) { log_in }
@ -19,7 +18,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
it "works on any post" do it "works on any post" do
@ -32,7 +31,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
it "schedules auto-close job" do it "schedules auto-close job" do
@ -45,9 +44,8 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["polls"][name]["close"]).to be expect(Poll.find_by(post_id: json["id"]).close_at).to be
expect(Jobs.scheduled_for(:close_poll, post_id: json["id"], poll_name: name)).to be
expect(Jobs.scheduled_for(:close_poll, post_id: Post.last.id, poll_name: name)).to be
end end
it "should have different options" do it "should have different options" do
@ -55,7 +53,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n- A\n[/poll]" title: title, raw: "[poll]\n- A\n- A\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_different_options")) expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_different_options"))
end end
@ -65,7 +63,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n[/poll]" title: title, raw: "[poll]\n- A\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_at_least_2_options")) expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_at_least_2_options"))
end end
@ -79,7 +77,7 @@ describe PostsController do
title: title, raw: raw title: title, raw: raw
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options)) expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options))
end end
@ -89,7 +87,7 @@ describe PostsController do
title: title, raw: "[poll type=multiple min=5]\n- A\n- B\n[/poll]" title: title, raw: "[poll type=multiple min=5]\n- A\n- B\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
end end
@ -103,7 +101,7 @@ describe PostsController do
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["cooked"]).to include("&lt;script&gt;") expect(json["cooked"]).to include("&lt;script&gt;")
expect(json["polls"]["&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"]).to be expect(Poll.find_by(post_id: json["id"]).name).to eq("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;")
end end
it "also works whe there is a link starting with '[poll'" do it "also works whe there is a link starting with '[poll'" do
@ -114,7 +112,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
it "prevents pollception" do it "prevents pollception" do
@ -125,8 +123,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["1"]).to_not be expect(Poll.where(post_id: json["id"]).count).to eq(1)
expect(json["polls"]["2"]).to be
end end
describe "edit window" do describe "edit window" do
@ -150,7 +147,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["post"]["polls"]["poll"]["options"][2]["html"]).to eq("C") expect(json["post"]["polls"][0]["options"][2]["html"]).to eq("C")
end end
it "resets the votes" do it "resets the votes" do
@ -191,26 +188,14 @@ describe PostsController do
describe "with no vote" do describe "with no vote" do
it "OP can change the options" do it "can change the options" do
put :update, params: { put :update, params: {
id: post_id, post: { raw: new_option } id: post_id, post: { raw: new_option }
}, format: :json }, format: :json
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["post"]["polls"]["poll"]["options"][1]["html"]).to eq("C") expect(json["post"]["polls"][0]["options"][1]["html"]).to eq("C")
end
it "staff can change the options" do
log_in_user(Fabricate(:moderator))
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["post"]["polls"]["poll"]["options"][1]["html"]).to eq("C")
end end
it "support changes on the post" do it "support changes on the post" do
@ -228,54 +213,19 @@ describe PostsController do
DiscoursePoll::Poll.vote(post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user) DiscoursePoll::Poll.vote(post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user)
end end
it "OP cannot change the options" do it "cannot change the options" do
put :update, params: { put :update, params: {
id: post_id, post: { raw: new_option } id: post_id, post: { raw: new_option }
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t( expect(json["errors"][0]).to eq(I18n.t(
"poll.edit_window_expired.op_cannot_edit_options", "poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: poll_edit_window_mins minutes: poll_edit_window_mins
)) ))
end end
it "staff can change the options and votes are merged" do
log_in_user(Fabricate(:moderator))
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["post"]["polls"]["poll"]["options"][1]["html"]).to eq("C")
expect(json["post"]["polls"]["poll"]["voters"]).to eq(1)
expect(json["post"]["polls"]["poll"]["options"][0]["votes"]).to eq(1)
expect(json["post"]["polls"]["poll"]["options"][1]["votes"]).to eq(0)
end
it "staff can change the options and anonymous votes are merged" do
post = Post.find_by(id: post_id)
default_poll = post.custom_fields["polls"]["poll"]
add_anonymous_votes(post, default_poll, 7, "5c24fc1df56d764b550ceae1b9319125" => 7)
log_in_user(Fabricate(:moderator))
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["post"]["polls"]["poll"]["options"][1]["html"]).to eq("C")
expect(json["post"]["polls"]["poll"]["voters"]).to eq(8)
expect(json["post"]["polls"]["poll"]["options"][0]["votes"]).to eq(8)
expect(json["post"]["polls"]["poll"]["options"][1]["votes"]).to eq(0)
end
it "support changes on the post" do it "support changes on the post" do
put :update, params: { id: post_id, post: { raw: updated } }, format: :json put :update, params: { id: post_id, post: { raw: updated } }, format: :json
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -298,7 +248,7 @@ describe PostsController do
title: title, raw: "[poll name=""foo""]\n- A\n- A\n[/poll]" title: title, raw: "[poll name=""foo""]\n- A\n- A\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_different_options", name: "foo")) expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_different_options", name: "foo"))
end end
@ -308,7 +258,7 @@ describe PostsController do
title: title, raw: "[poll name='foo']\n- A\n[/poll]" title: title, raw: "[poll name='foo']\n- A\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_at_least_2_options", name: "foo")) expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_at_least_2_options", name: "foo"))
end end
@ -325,8 +275,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.where(post_id: json["id"]).count).to eq(2)
expect(json["polls"]["foo"]).to be
end end
it "should have a name" do it "should have a name" do
@ -334,7 +283,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll]\n- A\n- B\n[/poll]" title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll]\n- A\n- B\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_without_name")) expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_without_name"))
end end
@ -344,7 +293,7 @@ describe PostsController do
title: title, raw: "[poll name=foo]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]" title: title, raw: "[poll name=foo]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_with_same_name", name: "foo")) expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_with_same_name", name: "foo"))
end end
@ -381,7 +330,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n- B\n[/poll]" title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json }, format: :json
expect(response).not_to be_success expect(response).not_to be_successful
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.insufficient_rights_to_create")) expect(json["errors"][0]).to eq(I18n.t("poll.insufficient_rights_to_create"))
end end
@ -402,7 +351,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
end end
@ -421,7 +370,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
end end
@ -440,7 +389,7 @@ describe PostsController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = ::JSON.parse(response.body) json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-") expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be expect(Poll.exists?(post_id: json["id"])).to eq(true)
end end
end end
end end

View File

@ -0,0 +1,330 @@
require 'rails_helper'
require_relative '../../../db/post_migrate/20180820080623_migrate_polls_data'
RSpec.describe MigratePollsData do
let!(:user) { Fabricate(:user, id: 1) }
let!(:user2) { Fabricate(:user, id: 2) }
let!(:user3) { Fabricate(:user, id: 3) }
let!(:user4) { Fabricate(:user, id: 4) }
let!(:user5) { Fabricate(:user, id: 5) }
let(:post) { Fabricate(:post, user: user) }
describe 'for a number poll' do
before do
post.custom_fields = {
"polls" => {
"poll" => {
"options" => [
{ "id" => "4d8a15e3cc35750f016ce15a43937620", "html" => "1", "votes" => 0 },
{ "id" => "aa2393b424f2f395abb63bf785760a3b", "html" => "4", "votes" => 0 },
{ "id" => "9ab1070dec27185440cdabb4948a5e9a", "html" => "7", "votes" => 1 },
{ "id" => "46c01f638a50d86e020f47469733b8be", "html" => "10", "votes" => 0 },
{ "id" => "b4f15431e07443c372d521e4ed131abe", "html" => "13", "votes" => 0 },
{ "id" => "4e885ead68ff4456f102843df9fbbd7f", "html" => "16", "votes" => 0 },
{ "id" => "eb8661f072794ea57baa7827cd8ffc88", "html" => "19", "votes" => 0 }
],
"voters" => 1,
"name" => "poll",
"status" => "open",
"type" => "number",
"min" => "1",
"max" => "20",
"step" => "3"
},
},
"polls-votes" => {
"1" => {
"poll" => [
"9ab1070dec27185440cdabb4948a5e9a"
]
}
}
}
post.save_custom_fields
end
it "should migrate the data correctly" do
expect do
silence_stdout { MigratePollsData.new.up }
end.to \
change { Poll.count }.by(1) &
change { PollOption.count }.by(7) &
change { PollVote.count }.by(1)
poll = Poll.find_by(name: "poll", post: post)
expect(poll.close_at).to eq(nil)
expect(poll.number?).to eq(true)
expect(poll.open?).to eq(true)
expect(poll.always?).to eq(true)
expect(poll.secret?).to eq(true)
expect(poll.min).to eq(1)
expect(poll.max).to eq(20)
expect(poll.step).to eq(3)
expect(PollOption.all.pluck(:digest, :html)).to eq([
["4d8a15e3cc35750f016ce15a43937620", "1"],
["aa2393b424f2f395abb63bf785760a3b", "4"],
["9ab1070dec27185440cdabb4948a5e9a", "7"],
["46c01f638a50d86e020f47469733b8be", "10"],
["b4f15431e07443c372d521e4ed131abe", "13"],
["4e885ead68ff4456f102843df9fbbd7f", "16"],
["eb8661f072794ea57baa7827cd8ffc88", "19"]
])
poll_vote = PollVote.first
expect(poll_vote.poll).to eq(poll)
expect(poll_vote.poll_option.html).to eq("7")
expect(poll_vote.user).to eq(user)
end
end
describe 'for a multiple poll' do
before do
post.custom_fields = {
"polls-votes" => {
"1" => {
"testing" => [
"b2c3e3668a886d09e97e38b8adde7d45",
"28df49fa9e9c09d3a1eb8cfbcdcda7790",
]
},
"2" => {
"testing" => [
"b2c3e3668a886d09e97e38b8adde7d45",
"d01af008ec373e948c0ab3ad61009f35",
]
},
},
"polls" => {
"poll" => {
"options" => [
{
"id" => "b2c3e3668a886d09e97e38b8adde7d45",
"html" => "Choice 1",
"votes" => 2,
"voter_ids" => [user.id, user2.id]
},
{
"id" => "28df49fa9e9c09d3a1eb8cfbcdcda7790",
"html" => "Choice 2",
"votes" => 1,
"voter_ids" => [user.id]
},
{
"id" => "d01af008ec373e948c0ab3ad61009f35",
"html" => "Choice 3",
"votes" => 1,
"voter_ids" => [user2.id]
},
],
"voters" => 4,
"name" => "testing",
"status" => "closed",
"type" => "multiple",
"public" => "true",
"min" => 1,
"max" => 2
}
}
}
post.save_custom_fields
end
it 'should migrate the data correctly' do
expect do
silence_stdout { MigratePollsData.new.up }
end.to \
change { Poll.count }.by(1) &
change { PollOption.count }.by(3) &
change { PollVote.count }.by(4)
poll = Poll.last
expect(poll.post_id).to eq(post.id)
expect(poll.name).to eq("testing")
expect(poll.close_at).to eq(nil)
expect(poll.multiple?).to eq(true)
expect(poll.closed?).to eq(true)
expect(poll.always?).to eq(true)
expect(poll.everyone?).to eq(true)
expect(poll.min).to eq(1)
expect(poll.max).to eq(2)
expect(poll.step).to eq(nil)
poll_options = PollOption.all
poll_option_1 = poll_options[0]
expect(poll_option_1.poll_id).to eq(poll.id)
expect(poll_option_1.digest).to eq("b2c3e3668a886d09e97e38b8adde7d45")
expect(poll_option_1.html).to eq("Choice 1")
poll_option_2 = poll_options[1]
expect(poll_option_2.poll_id).to eq(poll.id)
expect(poll_option_2.digest).to eq("28df49fa9e9c09d3a1eb8cfbcdcda7790")
expect(poll_option_2.html).to eq("Choice 2")
poll_option_3 = poll_options[2]
expect(poll_option_3.poll_id).to eq(poll.id)
expect(poll_option_3.digest).to eq("d01af008ec373e948c0ab3ad61009f35")
expect(poll_option_3.html).to eq("Choice 3")
expect(PollVote.all.pluck(:poll_id).uniq).to eq([poll.id])
{
user => [poll_option_1, poll_option_2],
user2 => [poll_option_1, poll_option_3]
}.each do |user, options|
options.each do |option|
expect(PollVote.exists?(poll_option_id: option.id, user_id: user.id))
.to eq(true)
end
end
end
end
describe 'for a regular poll' do
before do
post.custom_fields = {
"polls" => {
"testing" => {
"options" => [
{
"id" => "e94c09aae2aa071610212a5c5042111b",
"html" => "Yes",
"votes" => 0,
"anonymous_votes" => 1,
"voter_ids" => []
},
{
"id" => "802c50392a68e426d4b26d81ddc5ab33",
"html" => "No",
"votes" => 0,
"anonymous_votes" => 2,
"voter_ids" => []
}
],
"voters" => 0,
"anonymous_voters" => 3,
"name" => "testing",
"status" => "open",
"type" => "regular"
},
"poll" => {
"options" => [
{
"id" => "edeee5dae4802ab24185d41039efb545",
"html" => "Yes",
"votes" => 2,
"voter_ids" => [1, 2]
},
{
"id" => "38d8e35c8fc80590f836f22189064835",
"html" =>
"No",
"votes" => 3,
"voter_ids" => [3, 4, 5]
}
],
"voters" => 5,
"name" => "poll",
"status" => "open",
"type" => "regular",
"public" => "true",
"close" => "2018-10-08T00:00:00.000Z"
},
},
"polls-votes" => {
"1" => { "poll" => ["edeee5dae4802ab24185d41039efb545"] },
"2" => { "poll" => ["edeee5dae4802ab24185d41039efb545"] },
"3" => { "poll" => ["38d8e35c8fc80590f836f22189064835"] },
"4" => { "poll" => ["38d8e35c8fc80590f836f22189064835"] },
"5" => { "poll" => ["38d8e35c8fc80590f836f22189064835"] }
}
}
post.save_custom_fields
end
it 'should migrate the data correctly' do
expect do
silence_stdout { MigratePollsData.new.up }
end.to \
change { Poll.count }.by(2) &
change { PollOption.count }.by(4) &
change { PollVote.count }.by(5)
poll = Poll.find_by(name: "poll")
expect(poll.post_id).to eq(post.id)
expect(poll.close_at).to eq("2018-10-08T00:00:00.000Z")
expect(poll.regular?).to eq(true)
expect(poll.open?).to eq(true)
expect(poll.always?).to eq(true)
expect(poll.everyone?).to eq(true)
expect(poll.min).to eq(nil)
expect(poll.max).to eq(nil)
expect(poll.step).to eq(nil)
poll_options = PollOption.where(poll_id: poll.id).to_a
expect(poll_options.size).to eq(2)
option_1 = poll_options.first
expect(option_1.digest).to eq("edeee5dae4802ab24185d41039efb545")
expect(option_1.html).to eq("Yes")
option_2 = poll_options.last
expect(option_2.digest).to eq("38d8e35c8fc80590f836f22189064835")
expect(option_2.html).to eq("No")
expect(PollVote.pluck(:poll_id).uniq).to eq([poll.id])
[user, user2].each do |user|
expect(PollVote.exists?(poll_option_id: option_1.id, user_id: user.id))
.to eq(true)
end
[user3, user4, user5].each do |user|
expect(PollVote.exists?(poll_option_id: option_2.id, user_id: user.id))
.to eq(true)
end
poll = Poll.find_by(name: "testing")
expect(poll.post_id).to eq(post.id)
expect(poll.close_at).to eq(nil)
expect(poll.anonymous_voters).to eq(3)
expect(poll.regular?).to eq(true)
expect(poll.open?).to eq(true)
expect(poll.always?).to eq(true)
expect(poll.secret?).to eq(true)
expect(poll.min).to eq(nil)
expect(poll.max).to eq(nil)
expect(poll.step).to eq(nil)
poll_options = PollOption.where(poll: poll).to_a
expect(poll_options.size).to eq(2)
option_1 = poll_options.first
expect(option_1.digest).to eq("e94c09aae2aa071610212a5c5042111b")
expect(option_1.html).to eq("Yes")
expect(option_1.anonymous_votes).to eq(1)
option_2 = poll_options.last
expect(option_2.digest).to eq("802c50392a68e426d4b26d81ddc5ab33")
expect(option_2.html).to eq("No")
expect(option_2.anonymous_votes).to eq(2)
end
end
end

View File

@ -1,17 +0,0 @@
module Helpers
def add_anonymous_votes(post, poll, voters, options_with_votes)
poll["voters"] += voters
poll["anonymous_voters"] = voters
poll["options"].each do |option|
anonymous_votes = options_with_votes[option["id"]] || 0
if anonymous_votes > 0
option["votes"] += anonymous_votes
option["anonymous_votes"] = anonymous_votes
end
end
post.save_custom_fields(true)
end
end

View File

@ -4,12 +4,14 @@ describe "DiscoursePoll endpoints" do
describe "fetch voters for a poll" do describe "fetch voters for a poll" do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:post) { Fabricate(:post, raw: "[poll public=true]\n- A\n- B\n[/poll]") } let(:post) { Fabricate(:post, raw: "[poll public=true]\n- A\n- B\n[/poll]") }
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
it "should return the right response" do it "should return the right response" do
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
["5c24fc1df56d764b550ceae1b9319125"], [option_a],
user user
) )
@ -20,8 +22,8 @@ describe "DiscoursePoll endpoints" do
expect(response.status).to eq(200) expect(response.status).to eq(200)
poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] poll = JSON.parse(response.body)["voters"]
option = poll["5c24fc1df56d764b550ceae1b9319125"] option = poll[option_a]
expect(option.length).to eq(1) expect(option.length).to eq(1)
expect(option.first["id"]).to eq(user.id) expect(option.first["id"]).to eq(user.id)
@ -32,23 +34,23 @@ describe "DiscoursePoll endpoints" do
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
["5c24fc1df56d764b550ceae1b9319125", "e89dec30bbd9bf50fabf6a05b4324edf"], [option_a, option_b],
user user
) )
get "/polls/voters.json", params: { get "/polls/voters.json", params: {
post_id: post.id, post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME, poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
option_id: 'e89dec30bbd9bf50fabf6a05b4324edf' option_id: option_b
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] poll = JSON.parse(response.body)["voters"]
expect(poll['5c24fc1df56d764b550ceae1b9319125']).to eq(nil) expect(poll[option_a]).to eq(nil)
option = poll['e89dec30bbd9bf50fabf6a05b4324edf'] option = poll[option_b]
expect(option.length).to eq(1) expect(option.length).to eq(1)
expect(option.first["id"]).to eq(user.id) expect(option.first["id"]).to eq(user.id)
@ -68,7 +70,7 @@ describe "DiscoursePoll endpoints" do
post_id: -1, post_id: -1,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME poll_name: DiscoursePoll::DEFAULT_POLL_NAME
} }
expect(response.status).to eq(400) expect(response.status).to eq(422)
expect(response.body).to include('post_id is invalid') expect(response.body).to include('post_id is invalid')
end end
end end
@ -83,7 +85,7 @@ describe "DiscoursePoll endpoints" do
describe 'when poll_name is not valid' do describe 'when poll_name is not valid' do
it 'should raise the right error' do it 'should raise the right error' do
get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' } get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' }
expect(response.status).to eq(400) expect(response.status).to eq(422)
expect(response.body).to include('poll_name is invalid') expect(response.body).to include('poll_name is invalid')
end end
end end
@ -108,7 +110,7 @@ describe "DiscoursePoll endpoints" do
expect(response.status).to eq(200) expect(response.status).to eq(200)
poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME] poll = JSON.parse(response.body)["voters"]
expect(poll.first["id"]).to eq(user.id) expect(poll.first["id"]).to eq(user.id)
expect(poll.first["username"]).to eq(user.username) expect(poll.first["username"]).to eq(user.username)

View File

@ -1,15 +1,15 @@
require 'rails_helper' require "rails_helper"
describe NewPostManager do describe NewPostManager do
let(:user) { Fabricate(:newuser) } let(:user) { Fabricate(:newuser) }
let(:admin) { Fabricate(:admin) } let(:admin) { Fabricate(:admin) }
describe 'when new post containing a poll is queued for approval' do describe "when new post containing a poll is queued for approval" do
before do before do
SiteSetting.poll_minimum_trust_level_to_create = 0 SiteSetting.poll_minimum_trust_level_to_create = 0
end end
it 'should render the poll upon approval' do it "should render the poll upon approval" do
params = { params = {
raw: "[poll]\n* 1\n* 2\n* 3\n[/poll]", raw: "[poll]\n* 1\n* 2\n* 3\n[/poll]",
archetype: "regular", archetype: "regular",
@ -29,11 +29,9 @@ describe NewPostManager do
expect { NewPostManager.new(user, params).perform } expect { NewPostManager.new(user, params).perform }
.to change { QueuedPost.count }.by(1) .to change { QueuedPost.count }.by(1)
queued_post = QueuedPost.last QueuedPost.last.approve!(admin)
queued_post.approve!(admin)
expect(Post.last.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) expect(Poll.where(post: Post.last).exists?).to eq(true)
.to_not eq(nil)
end end
end end
end end

View File

@ -1,428 +1,192 @@
require 'rails_helper' require 'rails_helper'
describe DiscoursePoll::PollsUpdater do describe DiscoursePoll::PollsUpdater do
def update(post, polls)
DiscoursePoll::PollsUpdater.update(post, polls)
end
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:post_with_two_polls) do let(:post) {
raw = <<-RAW.strip_heredoc Fabricate(:post, raw: <<~RAW)
[poll]
* 1
* 2
[/poll]
[poll name=test]
* 1
* 2
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:post) do
raw = <<-RAW.strip_heredoc
[poll]
* 1
* 2
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:other_post) do
raw = <<-RAW.strip_heredoc
[poll]
* 3
* 4
* 5
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:polls) do
DiscoursePoll::PollsValidator.new(post).validate_polls
end
let(:polls_with_3_options) do
DiscoursePoll::PollsValidator.new(other_post).validate_polls
end
let(:two_polls) do
DiscoursePoll::PollsValidator.new(post_with_two_polls).validate_polls
end
describe '.update' do
describe 'when post does not contain any polls' do
it 'should update polls correctly' do
post = Fabricate(:post)
message = MessageBus.track_publish do
described_class.update(post, polls)
end.first
expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls]).to eq(polls)
end
end
describe 'when post contains existing polls' do
it "should be able to update polls correctly" do
message = MessageBus.track_publish do
described_class.update(post, polls_with_3_options)
end.first
expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls_with_3_options)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls]).to eq(polls_with_3_options)
end
end
describe 'when there are no changes' do
it "should not do anything" do
messages = MessageBus.track_publish do
described_class.update(post, polls)
end
expect(messages).to eq([])
end
end
context "public polls" do
let(:post) do
raw = <<-RAW.strip_heredoc
[poll public=true]
- A
- B
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:private_poll_post) do
raw = <<-RAW.strip_heredoc
[poll]
- A
- B
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:private_poll) do
DiscoursePoll::PollsValidator.new(private_poll_post).validate_polls
end
let(:public_poll) do
raw = <<-RAW.strip_heredoc
[poll public=true]
- A
- C
[/poll]
RAW
DiscoursePoll::PollsValidator.new(Fabricate(:post, raw: raw)).validate_polls
end
before do
DiscoursePoll::Poll.vote(post.id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user)
post.reload
end
it "should not allow a private poll with votes to be made public" do
DiscoursePoll::Poll.vote(private_poll_post.id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user)
private_poll_post.reload
messages = MessageBus.track_publish do
described_class.update(private_poll_post, public_poll)
end
expect(messages).to eq([])
expect(private_poll_post.errors[:base]).to include(
I18n.t("poll.default_cannot_be_made_public")
)
end
it "should retain voter_ids when options have been edited" do
described_class.update(post, public_poll)
polls = post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
expect(polls["poll"]["options"][0]["voter_ids"]).to eq([user.id])
expect(polls["poll"]["options"][1]["voter_ids"]).to eq([])
end
it "should delete voter_ids when poll is set to private" do
described_class.update(post, private_poll)
polls = post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD])
.to eq(private_poll)
expect(polls["poll"]["options"][0]["voter_ids"]).to eq(nil)
expect(polls["poll"]["options"][1]["voter_ids"]).to eq(nil)
end
end
context "polls of type 'multiple'" do
let(:min_2_post) do
raw = <<-RAW.strip_heredoc
[poll type=multiple min=2 max=3]
- Option 1
- Option 2
- Option 3
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:min_2_poll) do
DiscoursePoll::PollsValidator.new(min_2_post).validate_polls
end
let(:min_1_post) do
raw = <<-RAW.strip_heredoc
[poll type=multiple min=1 max=2]
- Option 1
- Option 2
- Option 3
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:min_1_poll) do
DiscoursePoll::PollsValidator.new(min_1_post).validate_polls
end
it "should be able to update options" do
min_2_poll
message = MessageBus.track_publish do
described_class.update(min_2_post, min_1_poll)
end.first
expect(min_2_post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(min_1_poll)
expect(message.data[:post_id]).to eq(min_2_post.id)
expect(message.data[:polls]).to eq(min_1_poll)
end
end
it 'should be able to edit multiple polls with votes' do
DiscoursePoll::Poll.vote(
post_with_two_polls.id,
"poll",
[two_polls["poll"]["options"].first["id"]],
user
)
raw = <<-RAW.strip_heredoc
[poll] [poll]
* 12 * 1
* 34 * 2
[/poll] [/poll]
RAW
}
[poll name=test] let(:post_with_3_options) {
* 12 Fabricate(:post, raw: <<~RAW)
* 34 [poll]
- a
- b
- c
[/poll] [/poll]
RAW RAW
}
different_post = Fabricate(:post, raw: raw) let(:post_with_some_attributes) {
different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls Fabricate(:post, raw: <<~RAW)
[poll close=#{1.week.from_now.to_formatted_s(:iso8601)} results=on_close]
- A
- B
- C
[/poll]
RAW
}
let(:polls) {
DiscoursePoll::PollsValidator.new(post).validate_polls
}
let(:polls_with_3_options) {
DiscoursePoll::PollsValidator.new(post_with_3_options).validate_polls
}
let(:polls_with_some_attributes) {
DiscoursePoll::PollsValidator.new(post_with_some_attributes).validate_polls
}
describe "update" do
it "does nothing when there are no changes" do
message = MessageBus.track_publish do message = MessageBus.track_publish do
described_class.update(post_with_two_polls.reload, different_polls) update(post, polls)
end.first end.first
expect(post_with_two_polls.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) expect(message).to be(nil)
.to eq(different_polls)
expect(message.data[:post_id]).to eq(post_with_two_polls.id)
expect(message.data[:polls]).to eq(different_polls)
end end
describe "when poll edit window has expired" do describe "deletes polls" do
let(:poll_edit_window_mins) { 6 }
let(:another_post) { Fabricate(:post, created_at: Time.zone.now - poll_edit_window_mins.minutes) }
before do it "that were removed" do
described_class.update(another_post, polls) update(post, {})
another_post.reload
SiteSetting.poll_edit_window_mins = poll_edit_window_mins
DiscoursePoll::Poll.vote( post.reload
another_post.id,
"poll", expect(Poll.where(post: post).exists?).to eq(false)
[polls["poll"]["options"].first["id"]], expect(post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(nil)
user
)
end end
it "should not allow users to edit options of current poll" do end
messages = MessageBus.track_publish do
described_class.update(another_post, polls_with_3_options) describe "creates polls" do
it "that were added" do
post = Fabricate(:post)
expect(Poll.find_by(post: post)).to_not be
message = MessageBus.track_publish do
update(post, polls)
end.first
poll = Poll.find_by(post: post)
expect(poll).to be
expect(poll.poll_options.size).to eq(2)
expect(poll.post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(true)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end
end
describe "updates polls" do
describe "when there are no votes" do
it "at any time" do
post # create the post
freeze_time 1.month.from_now
message = MessageBus.track_publish do
update(post, polls_with_some_attributes)
end.first
poll = Poll.find_by(post: post)
expect(poll).to be
expect(poll.poll_options.size).to eq(3)
expect(poll.poll_votes.size).to eq(0)
expect(poll.on_close?).to eq(true)
expect(poll.close_at).to be
expect(poll.post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(true)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end end
expect(another_post.errors[:base]).to include(I18n.t(
"poll.edit_window_expired.op_cannot_edit_options",
minutes: poll_edit_window_mins
))
expect(messages).to eq([])
end end
context "staff" do describe "when there are votes" do
let(:another_user) { Fabricate(:user) }
before do before do
another_post.update_attributes!(last_editor_id: User.staff.first.id) expect {
DiscoursePoll::Poll.vote(post.id, "poll", [polls["poll"]["options"][0]["id"]], user)
}.to change { PollVote.count }.by(1)
end end
it "should allow staff to add polls" do describe "inside the edit window" do
message = MessageBus.track_publish do
described_class.update(another_post, two_polls)
end.first
expect(another_post.errors.full_messages).to eq([]) it "and deletes the votes" do
message = MessageBus.track_publish do
update(post, polls_with_some_attributes)
end.first
expect(message.data[:post_id]).to eq(another_post.id) poll = Poll.find_by(post: post)
expect(message.data[:polls]).to eq(two_polls)
end
it "should not allow staff to add options if votes have been casted" do expect(poll).to be
another_post.update_attributes!(last_editor_id: User.staff.first.id) expect(poll.poll_options.size).to eq(3)
expect(poll.poll_votes.size).to eq(0)
expect(poll.on_close?).to eq(true)
expect(poll.close_at).to be
messages = MessageBus.track_publish do expect(poll.post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(true)
described_class.update(another_post, polls_with_3_options)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end end
expect(another_post.errors[:base]).to include(I18n.t(
"poll.edit_window_expired.staff_cannot_add_or_remove_options",
minutes: poll_edit_window_mins
))
expect(messages).to eq([])
end end
it "should allow staff to add options if no votes have been casted" do describe "outside the edit window" do
post.update_attributes!(
created_at: Time.zone.now - 5.minutes,
last_editor_id: User.staff.first.id
)
message = MessageBus.track_publish do it "throws an error" do
described_class.update(post, polls_with_3_options) edit_window = SiteSetting.poll_edit_window_mins
end.first
expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(polls_with_3_options) freeze_time (edit_window + 1).minutes.from_now
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls]).to eq(polls_with_3_options)
end
it "should allow staff to edit options even if votes have been casted" do update(post, polls_with_some_attributes)
another_post.update!(last_editor_id: User.staff.first.id)
DiscoursePoll::Poll.vote( poll = Poll.find_by(post: post)
another_post.id,
"poll",
[polls["poll"]["options"].first["id"]],
another_user
)
raw = <<-RAW.strip_heredoc expect(poll).to be
[poll] expect(poll.poll_options.size).to eq(2)
* 3 expect(poll.poll_votes.size).to eq(1)
* 4 expect(poll.on_close?).to eq(false)
[/poll] expect(poll.close_at).to_not be
RAW
different_post = Fabricate(:post, raw: raw) expect(post.errors[:base]).to include(
different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
message = MessageBus.track_publish do minutes: edit_window
described_class.update(another_post, different_polls) )
end.first )
custom_fields = another_post.reload.custom_fields
expect(custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD])
.to eq(different_polls)
[user, another_user].each do |u|
expect(custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD][u.id.to_s]["poll"])
.to eq(["68b434ff88aeae7054e42cd05a4d9056"])
end end
expect(message.data[:post_id]).to eq(another_post.id)
expect(message.data[:polls]).to eq(different_polls)
end end
it "should allow staff to edit options if votes have not been casted" do
post.update_attributes!(last_editor_id: User.staff.first.id)
raw = <<-RAW.strip_heredoc
[poll]
* 3
* 4
[/poll]
RAW
different_post = Fabricate(:post, raw: raw)
different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls
message = MessageBus.track_publish do
described_class.update(post, different_polls)
end.first
expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]).to eq(different_polls)
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls]).to eq(different_polls)
end
end end
end end
end end
describe '.extract_option_ids' do
it 'should return an array of the options id' do
expect(described_class.extract_option_ids(polls)).to eq(
["4d8a15e3cc35750f016ce15a43937620", "cd314db7dfbac2b10687b6f39abfdf41"]
)
end
end
describe '.total_votes' do
let!(:post) do
raw = <<-RAW.strip_heredoc
[poll]
* 1
* 2
[/poll]
[poll name=test]
* 1
* 2
[/poll]
RAW
Fabricate(:post, raw: raw)
end
it "should return the right number of votes" do
expect(described_class.total_votes(polls)).to eq(0)
polls.each { |key, value| value["voters"] = 2 }
expect(described_class.total_votes(polls)).to eq(4)
end
end
end end

View File

@ -1,11 +1,11 @@
require 'rails_helper' require "rails_helper"
describe ::DiscoursePoll::PollsValidator do describe ::DiscoursePoll::PollsValidator do
let(:post) { Fabricate(:post) } let(:post) { Fabricate(:post) }
subject { described_class.new(post) } subject { described_class.new(post) }
describe "#validate_polls" do describe "#validate_polls" do
it "should ensure that polls have unique names" do it "ensure that polls have unique names" do
raw = <<~RAW raw = <<~RAW
[poll] [poll]
* 1 * 1
@ -39,11 +39,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false) expect(post.update_attributes(raw: raw)).to eq(false)
expect(post.errors[:base]).to include( expect(post.errors[:base]).to include(
I18n.t("poll.multiple_polls_with_same_name", name: 'test') I18n.t("poll.multiple_polls_with_same_name", name: "test")
) )
end end
it 'should ensure that polls have unique options' do it "ensure that polls have unique options" do
raw = <<~RAW raw = <<~RAW
[poll] [poll]
* 1 * 1
@ -67,11 +67,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false) expect(post.update_attributes(raw: raw)).to eq(false)
expect(post.errors[:base]).to include( expect(post.errors[:base]).to include(
I18n.t("poll.named_poll_must_have_different_options", name: 'test') I18n.t("poll.named_poll_must_have_different_options", name: "test")
) )
end end
it 'should ensure that polls have at least 2 options' do it "ensure that polls have at least 2 options" do
raw = <<~RAW raw = <<~RAW
[poll] [poll]
* 1 * 1
@ -93,11 +93,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false) expect(post.update_attributes(raw: raw)).to eq(false)
expect(post.errors[:base]).to include( expect(post.errors[:base]).to include(
I18n.t("poll.named_poll_must_have_at_least_2_options", name: 'test') I18n.t("poll.named_poll_must_have_at_least_2_options", name: "test")
) )
end end
it "should ensure that polls' options do not exceed site settings" do it "ensure that polls options do not exceed site settings" do
SiteSetting.poll_maximum_options = 2 SiteSetting.poll_maximum_options = 2
raw = <<~RAW raw = <<~RAW
@ -127,12 +127,12 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.errors[:base]).to include(I18n.t( expect(post.errors[:base]).to include(I18n.t(
"poll.named_poll_must_have_less_options", "poll.named_poll_must_have_less_options",
name: 'test', count: SiteSetting.poll_maximum_options name: "test", count: SiteSetting.poll_maximum_options
)) ))
end end
describe 'multiple type polls' do describe "multiple type polls" do
it "should ensure that min should not be greater than max" do it "ensure that min < max" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple min=2 max=1] [poll type=multiple min=2 max=1]
* 1 * 1
@ -158,11 +158,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false) expect(post.update_attributes(raw: raw)).to eq(false)
expect(post.errors[:base]).to include( expect(post.errors[:base]).to include(
I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: 'test') I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: "test")
) )
end end
it "should ensure max setting is greater than 0" do it "ensure max > 0" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple max=-2] [poll type=multiple max=-2]
* 1 * 1
@ -177,7 +177,7 @@ describe ::DiscoursePoll::PollsValidator do
) )
end end
it "should ensure that max settings is smaller or equal to the number of options" do it "ensure that max <= number of options" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple max=3] [poll type=multiple max=3]
* 1 * 1
@ -192,7 +192,7 @@ describe ::DiscoursePoll::PollsValidator do
) )
end end
it "should ensure that min settings is not negative" do it "ensure that min > 0" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple min=-1] [poll type=multiple min=-1]
* 1 * 1
@ -207,7 +207,7 @@ describe ::DiscoursePoll::PollsValidator do
) )
end end
it "should ensure that min settings it not equal to zero" do it "ensure that min != 0" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple min=0] [poll type=multiple min=0]
* 1 * 1
@ -222,7 +222,7 @@ describe ::DiscoursePoll::PollsValidator do
) )
end end
it "should ensure that min settings is not equal to the number of options" do it "ensure that min != number of options" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple min=2] [poll type=multiple min=2]
* 1 * 1
@ -237,7 +237,7 @@ describe ::DiscoursePoll::PollsValidator do
) )
end end
it "should ensure that min settings is not greater than the number of options" do it "ensure that min < number of options" do
raw = <<~RAW raw = <<~RAW
[poll type=multiple min=3] [poll type=multiple min=3]
* 1 * 1

View File

@ -1,94 +0,0 @@
require 'rails_helper'
describe DiscoursePoll::VotesUpdater do
let(:target_user) { Fabricate(:user_single_email, username: 'alice', email: 'alice@example.com') }
let(:source_user) { Fabricate(:user_single_email, username: 'alice1', email: 'alice@work.com') }
let(:walter) { Fabricate(:walter_white) }
let(:target_user_id) { target_user.id.to_s }
let(:source_user_id) { source_user.id.to_s }
let(:walter_id) { walter.id.to_s }
let(:post_with_two_polls) do
raw = <<~RAW
[poll type=multiple min=2 max=3 public=true]
- Option 1
- Option 2
- Option 3
[/poll]
[poll name=private_poll]
- Option 1
- Option 2
- Option 3
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:option1_id) { "63eb791ab5d08fc4cc855a0703ac0dd1" }
let(:option2_id) { "773a193533027393806fff6edd6c04f7" }
let(:option3_id) { "f42f567ca3136ee1322d71d7745084c7" }
def vote(post, user, option_ids, poll_name = nil)
poll_name ||= DiscoursePoll::DEFAULT_POLL_NAME
DiscoursePoll::Poll.vote(post.id, poll_name, option_ids, user)
end
it "should move votes to the target_user when only the source_user voted" do
vote(post_with_two_polls, source_user, [option1_id, option3_id])
vote(post_with_two_polls, walter, [option1_id, option2_id])
DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user)
post_with_two_polls.reload
polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
expect(polls["poll"]["options"][0]["votes"]).to eq(2)
expect(polls["poll"]["options"][1]["votes"]).to eq(1)
expect(polls["poll"]["options"][2]["votes"]).to eq(1)
expect(polls["poll"]["options"][0]["voter_ids"]).to contain_exactly(target_user.id, walter.id)
expect(polls["poll"]["options"][1]["voter_ids"]).to contain_exactly(walter.id)
expect(polls["poll"]["options"][2]["voter_ids"]).to contain_exactly(target_user.id)
votes = post_with_two_polls.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
expect(votes.keys).to contain_exactly(target_user_id, walter_id)
expect(votes[target_user_id]["poll"]).to contain_exactly(option1_id, option3_id)
expect(votes[walter_id]["poll"]).to contain_exactly(option1_id, option2_id)
end
it "should delete votes of the source_user if the target_user voted" do
vote(post_with_two_polls, source_user, [option1_id, option3_id])
vote(post_with_two_polls, target_user, [option2_id, option3_id])
vote(post_with_two_polls, walter, [option1_id, option2_id])
DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user)
post_with_two_polls.reload
polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
expect(polls["poll"]["options"][0]["votes"]).to eq(1)
expect(polls["poll"]["options"][1]["votes"]).to eq(2)
expect(polls["poll"]["options"][2]["votes"]).to eq(1)
expect(polls["poll"]["options"][0]["voter_ids"]).to contain_exactly(walter.id)
expect(polls["poll"]["options"][1]["voter_ids"]).to contain_exactly(target_user.id, walter.id)
expect(polls["poll"]["options"][2]["voter_ids"]).to contain_exactly(target_user.id)
votes = post_with_two_polls.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
expect(votes.keys).to contain_exactly(target_user_id, walter_id)
expect(votes[target_user_id]["poll"]).to contain_exactly(option2_id, option3_id)
expect(votes[walter_id]["poll"]).to contain_exactly(option1_id, option2_id)
end
it "does not add voter_ids unless the poll is public" do
vote(post_with_two_polls, source_user, [option1_id, option3_id], "private_poll")
vote(post_with_two_polls, walter, [option1_id, option2_id], "private_poll")
DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user)
post_with_two_polls.reload
polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
polls["private_poll"]["options"].each { |o| expect(o).to_not have_key("voter_ids") }
end
end

View File

@ -70,8 +70,8 @@ test("Single Poll", async assert => {
edit_reason: null, edit_reason: null,
can_view_edit_history: true, can_view_edit_history: true,
wiki: false, wiki: false,
polls: { polls: [
poll: { {
options: [ options: [
{ {
id: "57ddd734344eb7436d64a7d68a0df444", id: "57ddd734344eb7436d64a7d68a0df444",
@ -88,7 +88,7 @@ test("Single Poll", async assert => {
status: "open", status: "open",
name: "poll" name: "poll"
}, },
test: { {
options: [ options: [
{ {
id: "c26ad90783b0d80936e5fdb292b7963c", id: "c26ad90783b0d80936e5fdb292b7963c",
@ -105,7 +105,7 @@ test("Single Poll", async assert => {
status: "open", status: "open",
name: "test" name: "test"
} }
} ]
} }
], ],
stream: [19] stream: [19]
@ -391,8 +391,8 @@ test("Public poll", async assert => {
edit_reason: null, edit_reason: null,
can_view_edit_history: true, can_view_edit_history: true,
wiki: false, wiki: false,
polls: { polls: [
poll: { {
options: [ options: [
{ {
id: "4d8a15e3cc35750f016ce15a43937620", id: "4d8a15e3cc35750f016ce15a43937620",
@ -418,7 +418,7 @@ test("Public poll", async assert => {
max: "3", max: "3",
public: "true" public: "true"
} }
} ]
} }
], ],
stream: [15] stream: [15]
@ -596,9 +596,199 @@ test("Public poll", async assert => {
server.get("/polls/voters.json", request => { // eslint-disable-line no-undef server.get("/polls/voters.json", request => { // eslint-disable-line no-undef
let body = {}; let body = {};
if (_.isEqual(request.queryParams, { post_id: "15", poll_name: "poll" })) { if (
request.queryParams["post_id"] === "15" &&
request.queryParams["poll_name"] === "poll" &&
request.queryParams["page"] === "1" &&
request.queryParams["option_id"] === "68b434ff88aeae7054e42cd05a4d9056"
) {
body = { body = {
poll: { voters: {
"68b434ff88aeae7054e42cd05a4d9056": [
{
id: 402,
username: "bruce400",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 409,
username: "bruce407",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 410,
username: "bruce408",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 411,
username: "bruce409",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 421,
username: "bruce419",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 422,
username: "bruce420",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 423,
username: "bruce421",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 426,
username: "bruce424",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 429,
username: "bruce427",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 437,
username: "bruce435",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 440,
username: "bruce438",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 442,
username: "bruce440",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 443,
username: "bruce441",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 445,
username: "bruce443",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 450,
username: "bruce448",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 451,
username: "bruce449",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 453,
username: "bruce451",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 455,
username: "bruce453",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 456,
username: "bruce454",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 461,
username: "bruce459",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 466,
username: "bruce464",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 468,
username: "bruce466",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 477,
username: "bruce475",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 478,
username: "bruce476",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 498,
username: "bruce496",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
}
]
}
};
} else if (
request.queryParams["post_id"] === "15" &&
request.queryParams["poll_name"] === "poll"
) {
body = {
voters: {
"68b434ff88aeae7054e42cd05a4d9056": [ "68b434ff88aeae7054e42cd05a4d9056": [
{ {
id: 402, id: 402,
@ -1132,195 +1322,6 @@ test("Public poll", async assert => {
] ]
} }
}; };
} else if (
_.isEqual(request.queryParams, {
post_id: "15",
poll_name: "poll",
offset: "1",
option_id: "68b434ff88aeae7054e42cd05a4d9056"
})
) {
body = {
poll: {
"68b434ff88aeae7054e42cd05a4d9056": [
{
id: 402,
username: "bruce400",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 409,
username: "bruce407",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 410,
username: "bruce408",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 411,
username: "bruce409",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 421,
username: "bruce419",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 422,
username: "bruce420",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 423,
username: "bruce421",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 426,
username: "bruce424",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 429,
username: "bruce427",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 437,
username: "bruce435",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 440,
username: "bruce438",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 442,
username: "bruce440",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 443,
username: "bruce441",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 445,
username: "bruce443",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 450,
username: "bruce448",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 451,
username: "bruce449",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 453,
username: "bruce451",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 455,
username: "bruce453",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 456,
username: "bruce454",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 461,
username: "bruce459",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 466,
username: "bruce464",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 468,
username: "bruce466",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 477,
username: "bruce475",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 478,
username: "bruce476",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 498,
username: "bruce496",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
}
]
}
};
} }
return [200, { "Content-Type": "application/json" }, body]; return [200, { "Content-Type": "application/json" }, body];
@ -1409,8 +1410,8 @@ test("Public number poll", async assert => {
edit_reason: null, edit_reason: null,
can_view_edit_history: true, can_view_edit_history: true,
wiki: false, wiki: false,
polls: { polls: [
poll: { {
options: [ options: [
{ {
id: "4d8a15e3cc35750f016ce15a43937620", id: "4d8a15e3cc35750f016ce15a43937620",
@ -1522,7 +1523,7 @@ test("Public number poll", async assert => {
step: "1", step: "1",
public: "true" public: "true"
} }
} ]
} }
], ],
stream: [16] stream: [16]
@ -1742,9 +1743,91 @@ test("Public number poll", async assert => {
server.get("/polls/voters.json", request => { // eslint-disable-line no-undef server.get("/polls/voters.json", request => { // eslint-disable-line no-undef
let body = {}; let body = {};
if (_.isEqual(request.queryParams, { post_id: "16", poll_name: "poll" })) { if (
request.queryParams["post_id"] === "16" &&
request.queryParams["poll_name"] === "poll" &&
request.queryParams["page"] === "1"
) {
body = { body = {
poll: [ voters: [
{
id: 418,
username: "bruce416",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 420,
username: "bruce418",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 423,
username: "bruce421",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 426,
username: "bruce424",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 428,
username: "bruce426",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 429,
username: "bruce427",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 432,
username: "bruce430",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 433,
username: "bruce431",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 434,
username: "bruce432",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 436,
username: "bruce434",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
}
]
};
} else if (
request.queryParams["post_id"] === "16" &&
request.queryParams["poll_name"] === "poll"
) {
body = {
voters: [
{ {
id: 402, id: 402,
username: "bruce400", username: "bruce400",
@ -1922,87 +2005,6 @@ test("Public number poll", async assert => {
} }
] ]
}; };
} else if (
_.isEqual(request.queryParams, {
post_id: "16",
poll_name: "poll",
offset: "1"
})
) {
body = {
poll: [
{
id: 418,
username: "bruce416",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 420,
username: "bruce418",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 423,
username: "bruce421",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 426,
username: "bruce424",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 428,
username: "bruce426",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 429,
username: "bruce427",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 432,
username: "bruce430",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 433,
username: "bruce431",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 434,
username: "bruce432",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
},
{
id: 436,
username: "bruce434",
avatar_template: "/images/avatar.png",
name: "Bruce Wayne",
title: null
}
]
};
} }
return [200, { "Content-Type": "application/json" }, body]; return [200, { "Content-Type": "application/json" }, body];

View File

@ -104,7 +104,7 @@ describe Migration::SafeMigrate do
migrate_up(path) migrate_up(path)
end end
expect(output).to include("drop_table(:users)") expect(output).to include("drop_table(:email_logs)")
end end
describe 'for a post deployment migration' do describe 'for a post deployment migration' do
@ -112,13 +112,13 @@ describe Migration::SafeMigrate do
user = Fabricate(:user) user = Fabricate(:user)
Migration::SafeMigrate::SafeMigration.enable_safe! Migration::SafeMigrate::SafeMigration.enable_safe!
path = File.expand_path "#{Rails.root}/spec/fixtures/db/post_migrate/drop_table" path = File.expand_path "#{Rails.root}/spec/fixtures/db/post_migrate"
output = capture_stdout do output = capture_stdout do
migrate_up(path) migrate_up(path)
end end
expect(output).to include("drop_table(:users)") expect(output).to include("drop_table(:email_logs)")
expect(user.reload).to eq(user) expect(user.reload).to eq(user)
end end
end end

View File

@ -1,6 +1,6 @@
class DropTable < ActiveRecord::Migration[5.1] class DropTable < ActiveRecord::Migration[5.1]
def up def up
drop_table :users drop_table :email_logs
end end
def down def down

View File

@ -1,6 +1,6 @@
class DropUsersTable < ActiveRecord::Migration[5.2] class DropEmailLogsTable < ActiveRecord::Migration[5.2]
def up def up
drop_table :users drop_table :email_logs
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end end

View File

@ -291,3 +291,10 @@ def has_trigger?(trigger_name)
WHERE trigger_name = '#{trigger_name}' WHERE trigger_name = '#{trigger_name}'
SQL SQL
end end
def silence_stdout
STDOUT.stubs(:write)
yield
ensure
STDOUT.unstub(:write)
end