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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1912 additions and 1573 deletions

View File

@ -1,5 +1,9 @@
class ChangeBounceScoreToFloat < ActiveRecord::Migration[5.2]
def change
def up
change_column :user_stats, :bounce_score, :float
end
def down
change_column :user_stats, :bounce_score, :integer
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",
multiplePollType: "multiple",
alwaysPollResult: "always",
votePollResult: "on_vote",
closedPollResult: "on_close",
init() {
this._super();
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")
isRegular(pollType, regularPollType) {
return pollType === regularPollType;
@ -128,6 +150,7 @@ export default Ember.Controller.extend({
"isNumber",
"showMinMax",
"pollType",
"pollResult",
"publicPoll",
"pollOptions",
"pollMin",
@ -141,6 +164,7 @@ export default Ember.Controller.extend({
isNumber,
showMinMax,
pollType,
pollResult,
publicPoll,
pollOptions,
pollMin,
@ -167,6 +191,7 @@ export default Ember.Controller.extend({
}
if (pollType) pollHeader += ` type=${pollType}`;
if (pollResult) pollHeader += ` results=${pollResult}`;
if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`;
if (pollMax) pollHeader += ` max=${pollMax}`;
if (isNumber) pollHeader += ` step=${step}`;

View File

@ -8,6 +8,15 @@
valueAttribute="value"}}
</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}}
<div class="input-group poll-number">
{{input-tip validation=minMaxValueValidation}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,13 @@ en:
average_rating: "Average rating: <strong>%{average}</strong>."
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:
help:
@ -85,6 +91,11 @@ en:
regular: Single Choice
multiple: Multiple Choice
number: Number Rating
poll_result:
label: Results
always: Always visible
vote: On vote
closed: When closed
poll_config:
max: Max
min: Min

View File

@ -43,14 +43,10 @@ en:
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:
op_cannot_edit_options: "You cannot add or remove poll options after the first %{minutes} minutes. Please contact a moderator if you need to edit a poll option."
staff_cannot_add_or_remove_options: "You cannot add or remove poll options after the first %{minutes} minutes. You should close this topic and create a new one instead."
cannot_edit_default_poll_with_votes: "You cannot change a poll after the first %{minutes} minutes."
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."
post_is_deleted: "Cannot act on a deleted post."

View File

@ -4,9 +4,12 @@ plugins:
client: true
poll_maximum_options:
default: 20
min: 2
max: 100
client: true
poll_edit_window_mins:
default: 5
min: 0
poll_minimum_trust_level_to_create:
default: 1
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
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)
# load previous polls
previous_polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] || {}
::Poll.transaction do
has_changed = false
edit_window = SiteSetting.poll_edit_window_mins
# extract options
current_option_ids = extract_option_ids(polls)
previous_option_ids = extract_option_ids(previous_polls)
old_poll_names = ::Poll.where(post: post).pluck(:name)
new_poll_names = polls.keys
# are the polls different?
if polls_updated?(polls, previous_polls) || (current_option_ids != previous_option_ids)
has_votes = total_votes(previous_polls) > 0
deleted_poll_names = old_poll_names - new_poll_names
created_poll_names = new_poll_names - old_poll_names
# outside of the edit window?
poll_edit_window_mins = SiteSetting.poll_edit_window_mins
# delete polls
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
# deal with option changes
if User.staff.where(id: post.last_editor_id).exists?
# staff can edit options
polls.each_key do |poll_name|
if polls.dig(poll_name, "options")&.size != previous_polls.dig(poll_name, "options")&.size && previous_polls.dig(poll_name, "voters").to_i > 0
post.errors.add(:base, I18n.t("poll.edit_window_expired.staff_cannot_add_or_remove_options", minutes: poll_edit_window_mins))
# create polls
if created_poll_names.present?
has_changed = true
polls.slice(*created_poll_names).values.each do |poll|
Poll.create!(post.id, poll)
end
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
end
end
# update poll
POLL_ATTRIBUTES.each do |attr|
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
if ::Poll.exists?(post: post)
post.custom_fields[HAS_POLLS] = true
else
# OP cannot edit poll options
post.errors.add(:base, I18n.t("poll.edit_window_expired.op_cannot_edit_options", minutes: poll_edit_window_mins))
return
end
post.custom_fields.delete(HAS_POLLS)
end
# try to merge votes
polls.each_key do |poll_name|
next unless previous_polls.has_key?(poll_name)
return if has_votes && private_to_public_poll?(post, previous_polls, polls, poll_name)
# when the # of options has changed, reset all the votes
if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size
PostCustomField.where(post_id: post.id, name: DiscoursePoll::VOTES_CUSTOM_FIELD).destroy_all
post.clear_custom_fields
next
end
polls[poll_name]["voters"] = previous_polls[poll_name]["voters"]
if previous_polls[poll_name].has_key?("anonymous_voters")
polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"]
end
previous_options = previous_polls[poll_name]["options"]
public_poll = polls[poll_name]["public"] == "true"
polls[poll_name]["options"].each_with_index do |option, index|
previous_option = previous_options[index]
option["votes"] = previous_option["votes"]
if previous_option["id"] != option["id"]
if votes_fields = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]
votes_fields.each do |key, value|
next unless value[poll_name]
index = value[poll_name].index(previous_option["id"])
votes_fields[key][poll_name][index] = option["id"] if index
end
end
end
if previous_option.has_key?("anonymous_votes")
option["anonymous_votes"] = previous_option["anonymous_votes"]
end
if public_poll && previous_option.has_key?("voter_ids")
option["voter_ids"] = previous_option["voter_ids"]
end
end
end
# immediately store the polls
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true)
# re-schedule jobs
DiscoursePoll::Poll.schedule_jobs(post)
# publish the changes
if has_changed
polls = ::Poll.includes(poll_options: :poll_votes).where(post: post)
polls = ActiveModel::ArraySerializer.new(polls, each_serializer: PollSerializer, root: false).as_json
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
end
end
def self.polls_updated?(current_polls, previous_polls)
return true if (current_polls.keys.sort != previous_polls.keys.sort)
current_polls.each_key do |poll_name|
if !previous_polls[poll_name] || (current_polls[poll_name].values_at(*VALID_POLLS_CONFIGS) != previous_polls[poll_name].values_at(*VALID_POLLS_CONFIGS))
return true
end
end
false
end
def self.extract_option_ids(polls)
polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort
end
def self.total_votes(polls)
polls.map { |key, value| value["voters"].to_i }.sum
end
private
def self.private_to_public_poll?(post, previous_polls, current_polls, poll_name)
previous_poll = previous_polls[poll_name]
current_poll = current_polls[poll_name]
if previous_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
def self.is_different?(old_poll, new_poll, new_options)
# an attribute was changed?
POLL_ATTRIBUTES.each do |attr|
return true if old_poll.send(attr) != new_poll.send(attr)
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
end
end
end

View File

@ -8,22 +8,11 @@ module DiscoursePoll
polls = {}
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)
# options must be unique
return false unless unique_options?(poll)
# at least 2 options
return false unless at_least_two_options?(poll)
# maximum # of options
return false unless valid_number_of_options?(poll)
# poll with multiple choices
return false unless valid_multiple_choice_settings?(poll)
# store the valid poll
polls[poll["name"]] = poll
end
@ -90,11 +79,11 @@ module DiscoursePoll
def valid_multiple_choice_settings?(poll)
if poll["type"] == "multiple"
num_of_options = poll["options"].size
options = poll["options"].size
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
@post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
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
# about: Official poll plugin for Discourse
# version: 0.9
# version: 1.0
# authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip)
# 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
hide_plugin if self.respond_to?(:hide_plugin)
PLUGIN_NAME ||= "discourse_poll".freeze
DATA_PREFIX ||= "data-poll-".freeze
PLUGIN_NAME ||= "discourse_poll"
DATA_PREFIX ||= "data-poll-"
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
DEFAULT_POLL_NAME ||= "poll".freeze
POLLS_CUSTOM_FIELD ||= "polls".freeze
VOTES_CUSTOM_FIELD ||= "polls-votes".freeze
autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator"
autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator"
autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater"
autoload :VotesUpdater, "#{Rails.root}/plugins/poll/lib/votes_updater"
HAS_POLLS ||= "has_polls"
DEFAULT_POLL_NAME ||= "poll"
class Engine < ::Rails::Engine
engine_name PLUGIN_NAME
@ -39,8 +45,7 @@ after_initialize do
class << self
def vote(post_id, poll_name, options, user)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
user_id = user.id
Poll.transaction do
post = Post.find_by(id: post_id)
# post must not be deleted
@ -49,85 +54,60 @@ after_initialize do
end
# 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")
end
# 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")
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?
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
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?
# 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) }
raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
poll["voters"] = poll["anonymous_voters"] || 0
all_options = Hash.new(0)
post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] ||= {}
post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {}
post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] = 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
new_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
obj << option.id if options.include?(option.digest)
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|
anonymous_votes = option["anonymous_votes"] || 0
option["votes"] = all_options[option["id"]] + anonymous_votes
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)
old_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
if option.poll_votes.any? { |v| v.user_id == user.id }
obj << option.id
end
end
# create missing votes
(new_option_ids - old_option_ids).each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true)
poll.reload
payload = { post_id: post_id, polls: polls }
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)
[poll, options]
[serialized_poll, options]
end
end
def toggle_status(post_id, poll_name, status, user_id)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
def toggle_status(post_id, poll_name, status, user)
Poll.transaction do
post = Post.find_by(id: post_id)
# post must not be deleted
@ -136,45 +116,142 @@ after_initialize do
end
# 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")
end
user = User.find_by(id: user_id)
# 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")
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) if polls[poll_name].blank?
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) unless poll
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
def voters(post_id, poll_name, user, opts = {})
post = Post.find_by(id: post_id)
raise Discourse::InvalidParameters.new("post_id is invalid") unless post
poll = Poll.find_by(post_id: post_id, name: poll_name)
raise Discourse::InvalidParameters.new("poll_name is invalid") unless poll&.can_see_voters?(user)
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
result
end
def schedule_jobs(post)
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].each do |name, poll|
Jobs.cancel_scheduled_job(:close_poll, post_id: post.id, poll_name: name)
Poll.where(post: post).find_each do |poll|
Jobs.cancel_scheduled_job(:close_poll, poll_id: poll.id)
if poll["status"] == "open" && poll["close"].present?
close_date =
begin
Time.zone.parse(poll["close"])
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
Jobs.enqueue_at(close_date, :close_poll, post_id: post.id, poll_name: name) if close_date && close_date > Time.zone.now
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
@ -184,7 +261,7 @@ after_initialize do
cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id)
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
p.attributes.values.each do |attribute|
@ -195,8 +272,8 @@ after_initialize do
# options
p.css("li[#{DATA_PREFIX}option-id]").each do |o|
option_id = o.attributes[DATA_PREFIX + "option-id"].value || ""
poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 }
option_id = o.attributes[DATA_PREFIX + "option-id"].value.to_s
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end
poll
@ -229,10 +306,9 @@ after_initialize do
post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
status = params.require(:status)
user_id = current_user.id
begin
poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, user_id)
poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, current_user)
render json: { poll: poll }
rescue StandardError => e
render_json_error e.message
@ -243,67 +319,13 @@ after_initialize do
post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
post = Post.find_by(id: post_id)
raise Discourse::InvalidParameters.new("post_id is invalid") if !post
raise Discourse::InvalidParameters.new("no poll exists for this post_id") unless post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]
opts = params.permit(:limit, :page, :option_id)
poll = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD][poll_name]
raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll
voter_limit = (params[:voter_limit] || 25).to_i
voter_limit = 0 if voter_limit < 0
voter_limit = 50 if voter_limit > 50
user_ids = []
options = poll["options"]
if poll["type"] != "number"
per_option_voters = {}
options.each do |option|
if (params[:option_id])
next unless option["id"] == params[:option_id].to_s
begin
render json: { voters: DiscoursePoll::Poll.voters(post_id, poll_name, current_user, opts) }
rescue StandardError => e
render_json_error e.message
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
render json: { poll_name => result }
end
end
@ -318,40 +340,41 @@ after_initialize do
end
Post.class_eval do
attr_accessor :polls
attr_accessor :extracted_polls
has_many :polls, dependent: :destroy
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
polls = self.polls
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
Poll.transaction do
polls.values.each do |poll|
DiscoursePoll::Poll.create!(post.id, poll)
end
post.custom_fields[DiscoursePoll::HAS_POLLS] = true
post.save_custom_fields(true)
end
end
end
validate(:post, :validate_polls) do |force = nil|
# only care when raw has changed!
return unless self.raw_changed? || force
validator = DiscoursePoll::PollsValidator.new(self)
return unless (polls = validator.validate_polls)
if !polls.empty?
if polls.present?
validator = DiscoursePoll::PostValidator.new(self)
return unless validator.validate_post
end
# are we updating a post?
if self.id.present?
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{self.id}") do
DiscoursePoll::PollsUpdater.update(self, polls)
end
else
self.polls = polls
self.extracted_polls = polls
end
true
@ -380,13 +403,6 @@ after_initialize do
end
end
register_post_custom_field_type(DiscoursePoll::POLLS_CUSTOM_FIELD, :json)
register_post_custom_field_type(DiscoursePoll::VOTES_CUSTOM_FIELD, :json)
topic_view_post_custom_fields_whitelister do |user|
user ? [DiscoursePoll::POLLS_CUSTOM_FIELD, DiscoursePoll::VOTES_CUSTOM_FIELD] : [DiscoursePoll::POLLS_CUSTOM_FIELD]
end
on(:reduce_cooked) do |fragment, post|
if post.nil? || post.trashed?
fragment.css(".poll, [data-poll-name]").each(&:remove)
@ -399,38 +415,83 @@ after_initialize do
end
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)
unless post.is_first_post?
polls = ActiveModel::ArraySerializer.new(post.polls, each_serializer: PollSerializer, root: false).as_json
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
end
end
on(:merging_users) do |source_user, target_user|
DiscoursePoll::VotesUpdater.merge_users!(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
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
preloaded_polls.map { |p| PollSerializer.new(p, root: false) }
end
add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? }
add_to_serializer(:post, :include_polls?) do
preloaded_polls.present?
end
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
add_to_serializer(:post, :include_polls_votes?) do
return unless scope.user
return unless post_custom_fields.present?
return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present?
post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}")
scope.user&.id.present? &&
preloaded_polls.present? &&
preloaded_polls.any? { |p| p.has_voted?(scope.user) }
end
end

View File

@ -1,5 +1,4 @@
require "rails_helper"
require_relative "../helpers"
describe ::DiscoursePoll::PollsController do
routes { ::DiscoursePoll::Engine.routes }
@ -8,6 +7,8 @@ describe ::DiscoursePoll::PollsController do
let(:topic) { Fabricate(:topic) }
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(: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
@ -56,7 +57,7 @@ describe ::DiscoursePoll::PollsController do
expect(json["poll"]["options"][1]["votes"]).to eq(1)
end
it "works even if topic is closed" do
it "works on closed topics" do
topic.update_attribute(:closed, true)
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"))
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
put :vote, params: {
post_id: poll.id, poll_name: "foobar", options: ["A"]
@ -135,8 +126,10 @@ describe ::DiscoursePoll::PollsController do
end
it "doesn't discard anonymous votes when someone votes" do
default_poll = poll.custom_fields["polls"]["poll"]
add_anonymous_votes(poll, default_poll, 17, "5c24fc1df56d764b550ceae1b9319125" => 11, "e89dec30bbd9bf50fabf6a05b4324edf" => 6)
the_poll = poll.polls.first
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: {
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"][1]["votes"]).to eq(6)
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
describe "#toggle_status" do
@ -248,13 +190,12 @@ describe ::DiscoursePoll::PollsController do
end
describe "votes" do
describe "#voters" do
let(:first) { "5c24fc1df56d764b550ceae1b9319125" }
let(:second) { "e89dec30bbd9bf50fabf6a05b4324edf" }
it "correctly handles offset" do
first = "5c24fc1df56d764b550ceae1b9319125"
second = "e89dec30bbd9bf50fabf6a05b4324edf"
user1 = log_in
put :vote, params: {
@ -274,15 +215,13 @@ describe ::DiscoursePoll::PollsController do
user3 = log_in
put :vote, params: {
post_id: multi_poll.id,
poll_name: "poll",
options: [first, second]
post_id: multi_poll.id, poll_name: "poll", options: [first, second]
}, format: :json
expect(response.status).to eq(200)
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
expect(response.status).to eq(200)
@ -290,25 +229,81 @@ describe ::DiscoursePoll::PollsController do
json = JSON.parse(response.body)
# no user3 cause voter_limit is 2
expect(json["poll"][first].map { |h| h["id"] }.sort).to eq([user1.id, user2.id])
expect(json["poll"][second].map { |h| h["id"] }).to eq([user3.id])
expect(json["voters"][first].map { |h| h["id"] }).to contain_exactly(user1.id, user2.id)
expect(json["voters"][second].map { |h| h["id"] }).to contain_exactly(user3.id)
end
reloaded = Post.find(multi_poll.id)
# break the custom poll and make sure we still return something sane here
# 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
it "ensures voters can only be seen after casting a vote" do
put :vote, params: {
post_id: public_poll_on_vote.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_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

View File

@ -1,5 +1,4 @@
require "rails_helper"
require_relative "../helpers"
describe PostsController do
let!(:user) { log_in }
@ -19,7 +18,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be
expect(Poll.exists?(post_id: json["id"])).to eq(true)
end
it "works on any post" do
@ -32,7 +31,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be
expect(Poll.exists?(post_id: json["id"])).to eq(true)
end
it "schedules auto-close job" do
@ -45,9 +44,8 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["polls"][name]["close"]).to be
expect(Jobs.scheduled_for(:close_poll, post_id: Post.last.id, poll_name: name)).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
end
it "should have different options" do
@ -55,7 +53,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n- A\n[/poll]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_different_options"))
end
@ -65,7 +63,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n[/poll]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_at_least_2_options"))
end
@ -79,7 +77,7 @@ describe PostsController do
title: title, raw: raw
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
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))
end
@ -89,7 +87,7 @@ describe PostsController do
title: title, raw: "[poll type=multiple min=5]\n- A\n- B\n[/poll]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
end
@ -103,7 +101,7 @@ describe PostsController do
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
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
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)
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]).to be
expect(Poll.exists?(post_id: json["id"])).to eq(true)
end
it "prevents pollception" do
@ -125,8 +123,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["1"]).to_not be
expect(json["polls"]["2"]).to be
expect(Poll.where(post_id: json["id"]).count).to eq(1)
end
describe "edit window" do
@ -150,7 +147,7 @@ describe PostsController do
expect(response.status).to eq(200)
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
it "resets the votes" do
@ -191,26 +188,14 @@ describe PostsController do
describe "with no vote" do
it "OP can change the options" do
it "can change the options" do
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
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")
expect(json["post"]["polls"][0]["options"][1]["html"]).to eq("C")
end
it "support changes on the post" do
@ -228,54 +213,19 @@ describe PostsController do
DiscoursePoll::Poll.vote(post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user)
end
it "OP cannot change the options" do
it "cannot change the options" do
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
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
))
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
put :update, params: { id: post_id, post: { raw: updated } }, format: :json
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]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_different_options", name: "foo"))
end
@ -308,7 +258,7 @@ describe PostsController do
title: title, raw: "[poll name='foo']\n- A\n[/poll]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_at_least_2_options", name: "foo"))
end
@ -325,8 +275,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json["cooked"]).to match("data-poll-")
expect(json["polls"]["poll"]).to be
expect(json["polls"]["foo"]).to be
expect(Poll.where(post_id: json["id"]).count).to eq(2)
end
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]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_without_name"))
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]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_with_same_name", name: "foo"))
end
@ -381,7 +330,7 @@ describe PostsController do
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
expect(response).not_to be_success
expect(response).not_to be_successful
json = ::JSON.parse(response.body)
expect(json["errors"][0]).to eq(I18n.t("poll.insufficient_rights_to_create"))
end
@ -402,7 +351,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
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
@ -421,7 +370,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
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
@ -440,7 +389,7 @@ describe PostsController do
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
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

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
let(:user) { Fabricate(:user) }
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
DiscoursePoll::Poll.vote(
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
["5c24fc1df56d764b550ceae1b9319125"],
[option_a],
user
)
@ -20,8 +22,8 @@ describe "DiscoursePoll endpoints" do
expect(response.status).to eq(200)
poll = JSON.parse(response.body)[DiscoursePoll::DEFAULT_POLL_NAME]
option = poll["5c24fc1df56d764b550ceae1b9319125"]
poll = JSON.parse(response.body)["voters"]
option = poll[option_a]
expect(option.length).to eq(1)
expect(option.first["id"]).to eq(user.id)
@ -32,23 +34,23 @@ describe "DiscoursePoll endpoints" do
DiscoursePoll::Poll.vote(
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
["5c24fc1df56d764b550ceae1b9319125", "e89dec30bbd9bf50fabf6a05b4324edf"],
[option_a, option_b],
user
)
get "/polls/voters.json", params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
option_id: 'e89dec30bbd9bf50fabf6a05b4324edf'
option_id: option_b
}
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.first["id"]).to eq(user.id)
@ -68,7 +70,7 @@ describe "DiscoursePoll endpoints" do
post_id: -1,
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')
end
end
@ -83,7 +85,7 @@ describe "DiscoursePoll endpoints" do
describe 'when poll_name is not valid' do
it 'should raise the right error' do
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')
end
end
@ -108,7 +110,7 @@ describe "DiscoursePoll endpoints" do
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["username"]).to eq(user.username)

View File

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

View File

@ -1,428 +1,192 @@
require 'rails_helper'
describe DiscoursePoll::PollsUpdater do
def update(post, polls)
DiscoursePoll::PollsUpdater.update(post, polls)
end
let(:user) { Fabricate(:user) }
let(:post_with_two_polls) do
raw = <<-RAW.strip_heredoc
[poll]
* 1
* 2
[/poll]
[poll name=test]
* 1
* 2
[/poll]
RAW
Fabricate(:post, raw: raw)
end
let(:post) do
raw = <<-RAW.strip_heredoc
let(:post) {
Fabricate(:post, raw: <<~RAW)
[poll]
* 1
* 2
[/poll]
RAW
}
Fabricate(:post, raw: raw)
end
let(:other_post) do
raw = <<-RAW.strip_heredoc
let(:post_with_3_options) {
Fabricate(:post, raw: <<~RAW)
[poll]
* 3
* 4
* 5
- a
- b
- c
[/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]
let(:post_with_some_attributes) {
Fabricate(:post, raw: <<~RAW)
[poll close=#{1.week.from_now.to_formatted_s(:iso8601)} results=on_close]
- 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
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
update(post, polls)
end.first
expect(message).to be(nil)
end
before do
DiscoursePoll::Poll.vote(post.id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user)
describe "deletes polls" do
it "that were removed" do
update(post, {})
post.reload
expect(Poll.where(post: post).exists?).to eq(false)
expect(post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(nil)
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([])
describe "creates polls" do
expect(private_poll_post.errors[:base]).to include(
I18n.t("poll.default_cannot_be_made_public")
)
end
it "that were added" do
post = Fabricate(:post)
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
expect(Poll.find_by(post: post)).to_not be
message = MessageBus.track_publish do
described_class.update(min_2_post, min_1_poll)
update(post, polls)
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
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
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
)
end
raw = <<-RAW.strip_heredoc
[poll]
* 12
* 34
[/poll]
describe "updates polls" do
[poll name=test]
* 12
* 34
[/poll]
RAW
describe "when there are no votes" do
different_post = Fabricate(:post, raw: raw)
different_polls = DiscoursePoll::PollsValidator.new(different_post).validate_polls
it "at any time" do
post # create the post
freeze_time 1.month.from_now
message = MessageBus.track_publish do
described_class.update(post_with_two_polls.reload, different_polls)
update(post, polls_with_some_attributes)
end.first
expect(post_with_two_polls.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD])
.to eq(different_polls)
poll = Poll.find_by(post: post)
expect(message.data[:post_id]).to eq(post_with_two_polls.id)
expect(message.data[:polls]).to eq(different_polls)
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
describe "when poll edit window has expired" do
let(:poll_edit_window_mins) { 6 }
let(:another_post) { Fabricate(:post, created_at: Time.zone.now - poll_edit_window_mins.minutes) }
end
describe "when there are votes" do
before do
described_class.update(another_post, polls)
another_post.reload
SiteSetting.poll_edit_window_mins = poll_edit_window_mins
DiscoursePoll::Poll.vote(
another_post.id,
"poll",
[polls["poll"]["options"].first["id"]],
user
)
expect {
DiscoursePoll::Poll.vote(post.id, "poll", [polls["poll"]["options"][0]["id"]], user)
}.to change { PollVote.count }.by(1)
end
it "should not allow users to edit options of current poll" do
messages = MessageBus.track_publish do
described_class.update(another_post, polls_with_3_options)
end
describe "inside the edit window" do
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
context "staff" do
let(:another_user) { Fabricate(:user) }
before do
another_post.update_attributes!(last_editor_id: User.staff.first.id)
end
it "should allow staff to add polls" do
it "and deletes the votes" do
message = MessageBus.track_publish do
described_class.update(another_post, two_polls)
update(post, polls_with_some_attributes)
end.first
expect(another_post.errors.full_messages).to eq([])
poll = Poll.find_by(post: post)
expect(message.data[:post_id]).to eq(another_post.id)
expect(message.data[:polls]).to eq(two_polls)
end
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
it "should not allow staff to add options if votes have been casted" do
another_post.update_attributes!(last_editor_id: User.staff.first.id)
expect(poll.post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(true)
messages = MessageBus.track_publish do
described_class.update(another_post, polls_with_3_options)
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
it "should allow staff to add options if no votes have been casted" do
post.update_attributes!(
created_at: Time.zone.now - 5.minutes,
last_editor_id: User.staff.first.id
)
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)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end
it "should allow staff to edit options even if votes have been casted" do
another_post.update!(last_editor_id: User.staff.first.id)
end
DiscoursePoll::Poll.vote(
another_post.id,
"poll",
[polls["poll"]["options"].first["id"]],
another_user
describe "outside the edit window" do
it "throws an error" do
edit_window = SiteSetting.poll_edit_window_mins
freeze_time (edit_window + 1).minutes.from_now
update(post, polls_with_some_attributes)
poll = Poll.find_by(post: post)
expect(poll).to be
expect(poll.poll_options.size).to eq(2)
expect(poll.poll_votes.size).to eq(1)
expect(poll.on_close?).to eq(false)
expect(poll.close_at).to_not be
expect(post.errors[:base]).to include(
I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: edit_window
)
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(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
expect(message.data[:post_id]).to eq(another_post.id)
expect(message.data[:polls]).to eq(different_polls)
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
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

View File

@ -1,11 +1,11 @@
require 'rails_helper'
require "rails_helper"
describe ::DiscoursePoll::PollsValidator do
let(:post) { Fabricate(:post) }
subject { described_class.new(post) }
describe "#validate_polls" do
it "should ensure that polls have unique names" do
it "ensure that polls have unique names" do
raw = <<~RAW
[poll]
* 1
@ -39,11 +39,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false)
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
it 'should ensure that polls have unique options' do
it "ensure that polls have unique options" do
raw = <<~RAW
[poll]
* 1
@ -67,11 +67,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false)
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
it 'should ensure that polls have at least 2 options' do
it "ensure that polls have at least 2 options" do
raw = <<~RAW
[poll]
* 1
@ -93,11 +93,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false)
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
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
raw = <<~RAW
@ -127,12 +127,12 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.errors[:base]).to include(I18n.t(
"poll.named_poll_must_have_less_options",
name: 'test', count: SiteSetting.poll_maximum_options
name: "test", count: SiteSetting.poll_maximum_options
))
end
describe 'multiple type polls' do
it "should ensure that min should not be greater than max" do
describe "multiple type polls" do
it "ensure that min < max" do
raw = <<~RAW
[poll type=multiple min=2 max=1]
* 1
@ -158,11 +158,11 @@ describe ::DiscoursePoll::PollsValidator do
expect(post.update_attributes(raw: raw)).to eq(false)
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
it "should ensure max setting is greater than 0" do
it "ensure max > 0" do
raw = <<~RAW
[poll type=multiple max=-2]
* 1
@ -177,7 +177,7 @@ describe ::DiscoursePoll::PollsValidator do
)
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
[poll type=multiple max=3]
* 1
@ -192,7 +192,7 @@ describe ::DiscoursePoll::PollsValidator do
)
end
it "should ensure that min settings is not negative" do
it "ensure that min > 0" do
raw = <<~RAW
[poll type=multiple min=-1]
* 1
@ -207,7 +207,7 @@ describe ::DiscoursePoll::PollsValidator do
)
end
it "should ensure that min settings it not equal to zero" do
it "ensure that min != 0" do
raw = <<~RAW
[poll type=multiple min=0]
* 1
@ -222,7 +222,7 @@ describe ::DiscoursePoll::PollsValidator do
)
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
[poll type=multiple min=2]
* 1
@ -237,7 +237,7 @@ describe ::DiscoursePoll::PollsValidator do
)
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
[poll type=multiple min=3]
* 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,
can_view_edit_history: true,
wiki: false,
polls: {
poll: {
polls: [
{
options: [
{
id: "57ddd734344eb7436d64a7d68a0df444",
@ -88,7 +88,7 @@ test("Single Poll", async assert => {
status: "open",
name: "poll"
},
test: {
{
options: [
{
id: "c26ad90783b0d80936e5fdb292b7963c",
@ -105,7 +105,7 @@ test("Single Poll", async assert => {
status: "open",
name: "test"
}
}
]
}
],
stream: [19]
@ -391,8 +391,8 @@ test("Public poll", async assert => {
edit_reason: null,
can_view_edit_history: true,
wiki: false,
polls: {
poll: {
polls: [
{
options: [
{
id: "4d8a15e3cc35750f016ce15a43937620",
@ -418,7 +418,7 @@ test("Public poll", async assert => {
max: "3",
public: "true"
}
}
]
}
],
stream: [15]
@ -596,9 +596,199 @@ test("Public poll", async assert => {
server.get("/polls/voters.json", request => { // eslint-disable-line no-undef
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 = {
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": [
{
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];
@ -1409,8 +1410,8 @@ test("Public number poll", async assert => {
edit_reason: null,
can_view_edit_history: true,
wiki: false,
polls: {
poll: {
polls: [
{
options: [
{
id: "4d8a15e3cc35750f016ce15a43937620",
@ -1522,7 +1523,7 @@ test("Public number poll", async assert => {
step: "1",
public: "true"
}
}
]
}
],
stream: [16]
@ -1742,9 +1743,91 @@ test("Public number poll", async assert => {
server.get("/polls/voters.json", request => { // eslint-disable-line no-undef
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 = {
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,
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];

View File

@ -104,7 +104,7 @@ describe Migration::SafeMigrate do
migrate_up(path)
end
expect(output).to include("drop_table(:users)")
expect(output).to include("drop_table(:email_logs)")
end
describe 'for a post deployment migration' do
@ -112,13 +112,13 @@ describe Migration::SafeMigrate do
user = Fabricate(:user)
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
migrate_up(path)
end
expect(output).to include("drop_table(:users)")
expect(output).to include("drop_table(:email_logs)")
expect(user.reload).to eq(user)
end
end

View File

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

View File

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

View File

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