FEATURE: automatically close a poll on a given date and time

This commit is contained in:
Régis Hanol
2018-05-03 02:12:19 +02:00
parent de6dd2dc02
commit ba14c80b9c
15 changed files with 204 additions and 217 deletions

View File

@ -426,21 +426,24 @@
} }
} }
.change-timestamp { .change-timestamp,
.poll-ui-builder {
.date-picker { .date-picker {
width: 10em; width: 9em;
} }
#date-container { #date-container {
.pika-single { .pika-single {
position: relative !important; // overriding another important position: relative !important; // overriding another important
display: inline-block; display: inline-block;
margin-top: 5px;
} }
} }
input[type=time] { input[type=time] {
width: 6em; width: 6em;
text-align: center;
} }
form { form {

View File

@ -13,13 +13,11 @@ export default Ember.Controller.extend({
@computed("regularPollType", "numberPollType", "multiplePollType") @computed("regularPollType", "numberPollType", "multiplePollType")
pollTypes(regularPollType, numberPollType, multiplePollType) { pollTypes(regularPollType, numberPollType, multiplePollType) {
let types = []; return [
{ name: I18n.t("poll.ui_builder.poll_type.regular"), value: regularPollType },
types.push({ name: I18n.t("poll.ui_builder.poll_type.regular"), value: regularPollType }); { name: I18n.t("poll.ui_builder.poll_type.number"), value: numberPollType },
types.push({ name: I18n.t("poll.ui_builder.poll_type.number"), value: numberPollType }); { name: I18n.t("poll.ui_builder.poll_type.multiple"), value: multiplePollType },
types.push({ name: I18n.t("poll.ui_builder.poll_type.multiple"), value: multiplePollType }); ];
return types;
}, },
@computed("pollType", "regularPollType") @computed("pollType", "regularPollType")
@ -101,8 +99,8 @@ export default Ember.Controller.extend({
return this._comboboxOptions(1, (parseInt(pollMax) || 1) + 1); return this._comboboxOptions(1, (parseInt(pollMax) || 1) + 1);
}, },
@computed("isNumber", "showMinMax", "pollType", "publicPoll", "pollOptions", "pollMin", "pollMax", "pollStep") @computed("isNumber", "showMinMax", "pollType", "publicPoll", "pollOptions", "pollMin", "pollMax", "pollStep", "autoClose", "date", "time")
pollOutput(isNumber, showMinMax, pollType, publicPoll, pollOptions, pollMin, pollMax, pollStep) { pollOutput(isNumber, showMinMax, pollType, publicPoll, pollOptions, pollMin, pollMax, pollStep, autoClose, date, time) {
let pollHeader = '[poll'; let pollHeader = '[poll';
let output = ''; let output = '';
@ -113,15 +111,15 @@ export default Ember.Controller.extend({
}; };
let step = pollStep; let step = pollStep;
if (step < 1) { if (step < 1) { step = 1; }
step = 1;
}
if (pollType) pollHeader += ` type=${pollType}`; if (pollType) pollHeader += ` type=${pollType}`;
if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`; if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`;
if (pollMax) pollHeader += ` max=${pollMax}`; if (pollMax) pollHeader += ` max=${pollMax}`;
if (isNumber) pollHeader += ` step=${step}`; if (isNumber) pollHeader += ` step=${step}`;
if (publicPoll) pollHeader += ' public=true'; if (publicPoll) pollHeader += ` public=true`;
if (autoClose) pollHeader += ` close=${moment(date + " " + time, "YYYY-MM-DD HH:mm").toISOString()}`;
pollHeader += ']'; pollHeader += ']';
output += `${pollHeader}\n`; output += `${pollHeader}\n`;
@ -186,7 +184,10 @@ export default Ember.Controller.extend({
pollOptions: '', pollOptions: '',
pollMin: 1, pollMin: 1,
pollMax: null, pollMax: null,
pollStep: 1 pollStep: 1,
autoClose: false,
date: moment().add(1, "day").format("YYYY-DD-MM"),
time: moment().add(1, "hour").format("HH:mm"),
}); });
}, },

View File

@ -40,13 +40,6 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
<div class="input-group">
<label>
{{input type='checkbox' checked=publicPoll}}
{{i18n "poll.ui_builder.poll_public.label"}}
</label>
</div>
{{#unless isNumber}} {{#unless isNumber}}
<div class="input-group"> <div class="input-group">
<label>{{i18n 'poll.ui_builder.poll_options.label'}}</label> <label>{{i18n 'poll.ui_builder.poll_options.label'}}</label>
@ -54,6 +47,28 @@
{{textarea value=pollOptions}} {{textarea value=pollOptions}}
</div> </div>
{{/unless}} {{/unless}}
<div class="input-group">
<label>
{{input type='checkbox' checked=publicPoll}}
{{i18n "poll.ui_builder.poll_public.label"}}
</label>
</div>
<div class="input-group">
<label>
{{input type="checkbox" checked=autoClose}}
{{i18n "poll.ui_builder.automatic_close.label"}}
</label>
</div>
{{#if autoClose}}
<div class="input-group">
{{date-picker-future value=date containerId="date-container"}}
{{input type="time" value=time}}
<div id="date-container"></div>
</div>
{{/if}}
</form> </form>
{{/d-modal-body}} {{/d-modal-body}}

View File

@ -22,6 +22,13 @@ function initializePolls(api) {
} }
}); });
let _glued = [];
let _interval = null;
function rerender() {
_glued.forEach(g => g.queueRerender());
}
api.modifyClass('model:post', { api.modifyClass('model:post', {
_polls: null, _polls: null,
pollsObject: null, pollsObject: null,
@ -41,12 +48,12 @@ function initializePolls(api) {
} }
}); });
this.set("pollsObject", this._polls); this.set("pollsObject", this._polls);
_glued.forEach(g => g.queueRerender()); rerender();
} }
} }
}); });
const _glued = [];
function attachPolls($elem, helper) { function attachPolls($elem, helper) {
const $polls = $('.poll', $elem); const $polls = $('.poll', $elem);
if (!$polls.length) { return; } if (!$polls.length) { return; }
@ -60,6 +67,8 @@ function initializePolls(api) {
const polls = post.get("pollsObject"); const polls = post.get("pollsObject");
if (!polls) { return; } if (!polls) { return; }
_interval = _interval || setInterval(rerender, 30000);
$polls.each((idx, pollElem) => { $polls.each((idx, pollElem) => {
const $poll = $(pollElem); const $poll = $(pollElem);
const pollName = $poll.data("poll-name"); const pollName = $poll.data("poll-name");
@ -81,7 +90,13 @@ function initializePolls(api) {
} }
function cleanUpPolls() { function cleanUpPolls() {
if (_interval) {
clearInterval(_interval);
_interval = null;
}
_glued.forEach(g => g.cleanUp()); _glued.forEach(g => g.cleanUp());
_glued = [];
} }
api.includePostAttributes("polls", "polls_votes"); api.includePostAttributes("polls", "polls_votes");

View File

@ -2,35 +2,7 @@
const DATA_PREFIX = "data-poll-"; const DATA_PREFIX = "data-poll-";
const DEFAULT_POLL_NAME = "poll"; const DEFAULT_POLL_NAME = "poll";
const WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status", "public"]; const WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status", "public", "close"];
function getHelpText(count, min, max) {
// default values
if (isNaN(min) || min < 1) { min = 1; }
if (isNaN(max) || max > count) { max = count; }
// add some help text
let help;
if (max > 0) {
if (min === max) {
if (min > 1) {
help = I18n.t("poll.multiple.help.x_options", { count: min });
}
} else if (min > 1) {
if (max < count) {
help = I18n.t("poll.multiple.help.between_min_and_max_options", { min: min, max: max });
} else {
help = I18n.t("poll.multiple.help.at_least_min_options", { count: min });
}
} else if (max <= count) {
help = I18n.t("poll.multiple.help.up_to_max_options", { count: max });
}
}
return help;
}
function replaceToken(tokens, target, list) { function replaceToken(tokens, target, list) {
let pos = tokens.indexOf(target); let pos = tokens.indexOf(target);
@ -50,7 +22,6 @@ function replaceToken(tokens, target, list) {
// analyzes the block to that we have poll options // analyzes the block to that we have poll options
function getListItems(tokens, startToken) { function getListItems(tokens, startToken) {
let i = tokens.length-1; let i = tokens.length-1;
let listItems = []; let listItems = [];
let buffer = []; let buffer = [];
@ -217,63 +188,13 @@ const rule = {
token = state.push('span_open', 'span', 1); token = state.push('span_open', 'span', 1);
token.block = false; token.block = false;
token.attrs = [['class', 'info-text']]; token.attrs = [['class', 'info-label']];
token = state.push('text', '', 0); token = state.push('text', '', 0);
token.content = I18n.t("poll.voters", { count: 0 }); token.content = I18n.t("poll.voters", { count: 0 });
state.push('span_close', 'span', -1); state.push('span_close', 'span', -1);
state.push('paragraph_close', 'p', -1); state.push('paragraph_close', 'p', -1);
// multiple help text
if (attributes[DATA_PREFIX + "type"] === "multiple") {
let help = getHelpText(items.length, min, max);
if (help) {
state.push('paragraph_open', 'p', 1);
token = state.push('html_inline', '', 0);
token.content = help;
state.push('paragraph_close', 'p', -1);
}
}
if (attributes[DATA_PREFIX + 'public'] === 'true') {
state.push('paragraph_open', 'p', 1);
token = state.push('text', '', 0);
token.content = I18n.t('poll.public.title');
state.push('paragraph_close', 'p', -1);
}
state.push('poll_close', 'div', -1);
state.push('poll_close', 'div', -1);
token = state.push('poll_open', 'div', 1);
token.attrs = [['class', 'poll-buttons']];
if (attributes[DATA_PREFIX + 'type'] === 'multiple') {
token = state.push('link_open', 'a', 1);
token.block = false;
token.attrs = [
['class', 'button cast-votes'],
['title', I18n.t('poll.cast-votes.title')]
];
token = state.push('text', '', 0);
token.content = I18n.t('poll.cast-votes.label');
state.push('link_close', 'a', -1);
}
token = state.push('link_open', 'a', 1);
token.block = false;
token.attrs = [
['class', 'button toggle-results'],
['title', I18n.t('poll.show-results.title')]
];
token = state.push('text', '', 0);
token.content = I18n.t("poll.show-results.label");
state.push('link_close', 'a', -1);
state.push('poll_close', 'div', -1); state.push('poll_close', 'div', -1);
state.push('poll_close', 'div', -1); state.push('poll_close', 'div', -1);
} }
@ -299,6 +220,7 @@ export function setup(helper) {
'div[data-*]', 'div[data-*]',
'span.info-number', 'span.info-number',
'span.info-text', 'span.info-text',
'span.info-label',
'a.button.cast-votes', 'a.button.cast-votes',
'a.button.toggle-results', 'a.button.toggle-results',
'li[data-*]' 'li[data-*]'

View File

@ -34,7 +34,6 @@ createWidget('discourse-poll-option', {
html(attrs) { html(attrs) {
const result = []; const result = [];
const { option, vote } = attrs; const { option, vote } = attrs;
const chosen = vote.indexOf(option.id) !== -1; const chosen = vote.indexOf(option.id) !== -1;
@ -45,6 +44,7 @@ createWidget('discourse-poll-option', {
} }
result.push(' '); result.push(' ');
result.push(optionHtml(option)); result.push(optionHtml(option));
return result; return result;
}, },
@ -235,7 +235,6 @@ createWidget('discourse-poll-number-results', {
const { attrs, state } = this; const { attrs, state } = this;
if (state.loaded === 'new') { if (state.loaded === 'new') {
fetchVoters({ fetchVoters({
post_id: attrs.post.id, post_id: attrs.post.id,
poll_name: attrs.poll.get('name') poll_name: attrs.poll.get('name')
@ -258,8 +257,7 @@ createWidget('discourse-poll-number-results', {
const voters = poll.voters; const voters = poll.voters;
const average = voters === 0 ? 0 : round(totalScore / voters, -2); const average = voters === 0 ? 0 : round(totalScore / voters, -2);
const averageRating = I18n.t("poll.average_rating", { average }); const averageRating = I18n.t("poll.average_rating", { average });
const results = [h('div.poll-results-number-rating', const results = [h('div.poll-results-number-rating', new RawHtml({ html: `<span>${averageRating}</span>` }))];
new RawHtml({ html: `<span>${averageRating}</span>` }))];
if (isPublic) { if (isPublic) {
this.fetchVoters(); this.fetchVoters();
@ -283,7 +281,7 @@ createWidget('discourse-poll-container', {
html(attrs) { html(attrs) {
const { poll } = attrs; const { poll } = attrs;
if (attrs.showResults) { if (attrs.showResults || attrs.isClosed) {
const type = poll.get('type') === 'number' ? 'number' : 'standard'; const type = poll.get('type') === 'number' ? 'number' : 'standard';
return this.attach(`discourse-poll-${type}-results`, attrs); return this.attach(`discourse-poll-${type}-results`, attrs);
} }
@ -327,29 +325,37 @@ createWidget('discourse-poll-info', {
const count = poll.get('voters'); const count = poll.get('voters');
const result = [h('p', [ const result = [h('p', [
h('span.info-number', count.toString()), h('span.info-number', count.toString()),
h('span.info-text', I18n.t('poll.voters', { count })) h('span.info-label', I18n.t('poll.voters', { count }))
])]; ])];
if (attrs.isMultiple) { if (attrs.isMultiple) {
if (attrs.showResults) { if (attrs.showResults || attrs.isClosed) {
const totalVotes = poll.get('options').reduce((total, o) => { const totalVotes = poll.get('options').reduce((total, o) => {
return total + parseInt(o.votes, 10); return total + parseInt(o.votes, 10);
}, 0); }, 0);
result.push(h('p', [ result.push(h('p', [
h('span.info-number', totalVotes.toString()), h('span.info-number', totalVotes.toString()),
h('span.info-text', I18n.t("poll.total_votes", { count: totalVotes })) h('span.info-label', I18n.t("poll.total_votes", { count: totalVotes }))
])); ]));
} else { } else {
const help = this.multipleHelpText(attrs.min, attrs.max, poll.get('options.length')); const help = this.multipleHelpText(attrs.min, attrs.max, poll.get('options.length'));
if (help) { if (help) {
result.push(new RawHtml({ html: `<span>${help}</span>` })); result.push(new RawHtml({ html: `<span class="info-text">${help}</span>` }));
} }
} }
} }
if (!attrs.showResults && attrs.poll.get('public')) { if (!attrs.isClosed) {
result.push(h('p', I18n.t('poll.public.title'))); if (!attrs.showResults && poll.get('public')) {
result.push(h('span.info-text', I18n.t('poll.public.title')));
}
if (poll.close) {
const closeDate = moment.utc(poll.close);
const timeLeft = moment().to(closeDate.local(), true);
result.push(new RawHtml({ html: `<span class="info-text" title="${closeDate.format("LLL")}">${I18n.t("poll.automatic_close.closes_in", { timeLeft })}</span>` }));
}
} }
return result; return result;
@ -363,8 +369,8 @@ createWidget('discourse-poll-buttons', {
const results = []; const results = [];
const { poll, post } = attrs; const { poll, post } = attrs;
const topicArchived = post.get('topic.archived'); const topicArchived = post.get('topic.archived');
const isClosed = poll.get('status') === 'closed'; const closed = attrs.isClosed;
const hideResultsDisabled = isClosed || topicArchived; const hideResultsDisabled = closed || topicArchived;
if (attrs.isMultiple && !hideResultsDisabled) { if (attrs.isMultiple && !hideResultsDisabled) {
const castVotesDisabled = !attrs.canCastVotes; const castVotesDisabled = !attrs.canCastVotes;
@ -378,7 +384,7 @@ createWidget('discourse-poll-buttons', {
results.push(' '); results.push(' ');
} }
if (attrs.showResults) { if (attrs.showResults || hideResultsDisabled) {
results.push(this.attach('button', { results.push(this.attach('button', {
className: 'btn toggle-results', className: 'btn toggle-results',
label: 'poll.hide-results.label', label: 'poll.hide-results.label',
@ -403,14 +409,16 @@ createWidget('discourse-poll-buttons', {
this.currentUser.get("staff")) && this.currentUser.get("staff")) &&
!topicArchived) { !topicArchived) {
if (isClosed) { if (closed) {
results.push(this.attach('button', { if (!attrs.isAutomaticallyClosed) {
className: 'btn toggle-status', results.push(this.attach('button', {
label: 'poll.open.label', className: 'btn toggle-status',
title: 'poll.open.title', label: 'poll.open.label',
icon: 'unlock-alt', title: 'poll.open.title',
action: 'toggleStatus' icon: 'unlock-alt',
})); action: 'toggleStatus'
}));
}
} else { } else {
results.push(this.attach('button', { results.push(this.attach('button', {
className: 'btn toggle-status btn-danger', className: 'btn toggle-status btn-danger',
@ -422,7 +430,6 @@ createWidget('discourse-poll-buttons', {
} }
} }
return results; return results;
} }
}); });
@ -437,14 +444,14 @@ export default createWidget('discourse-poll', {
"data-poll-type": poll.get('type'), "data-poll-type": poll.get('type'),
"data-poll-name": poll.get('name'), "data-poll-name": poll.get('name'),
"data-poll-status": poll.get('status'), "data-poll-status": poll.get('status'),
"data-poll-public": poll.get('public') "data-poll-public": poll.get('public'),
"data-poll-close": poll.get('close'),
}; };
}, },
defaultState(attrs) { defaultState(attrs) {
const { poll, post } = attrs; const showResults = this.isClosed() || attrs.post.get('topic.archived');
return { loading: false, return { loading: false, showResults };
showResults: poll.get('isClosed') || post.get('topic.archived') };
}, },
html(attrs, state) { html(attrs, state) {
@ -452,9 +459,12 @@ export default createWidget('discourse-poll', {
const newAttrs = jQuery.extend({}, attrs, { const newAttrs = jQuery.extend({}, attrs, {
showResults, showResults,
canCastVotes: this.canCastVotes(), canCastVotes: this.canCastVotes(),
isClosed: this.isClosed(),
isAutomaticallyClosed: this.isAutomaticallyClosed(),
min: this.min(), min: this.min(),
max: this.max() max: this.max()
}); });
return h('div', [ return h('div', [
this.attach('discourse-poll-container', newAttrs), this.attach('discourse-poll-container', newAttrs),
this.attach('discourse-poll-info', newAttrs), this.attach('discourse-poll-info', newAttrs),
@ -462,10 +472,6 @@ export default createWidget('discourse-poll', {
]); ]);
}, },
isClosed() {
return this.attrs.poll.get('status') === "closed";
},
min() { min() {
let min = parseInt(this.attrs.poll.min, 10); let min = parseInt(this.attrs.poll.min, 10);
if (isNaN(min) || min < 1) { min = 1; } if (isNaN(min) || min < 1) { min = 1; }
@ -479,37 +485,51 @@ export default createWidget('discourse-poll', {
return max; return max;
}, },
isAutomaticallyClosed() {
const { poll } = this.attrs;
return poll.get("close") && moment.utc(poll.get("close")) <= moment();
},
isClosed() {
const { poll } = this.attrs;
return poll.get("status") === "closed" || this.isAutomaticallyClosed();
},
canCastVotes() { canCastVotes() {
const { state, attrs } = this; const { state, attrs } = this;
if (this.isClosed() || state.showResults || state.loading) { if (this.isClosed() || state.showResults || state.loading) {
return false; return false;
} }
const selectedOptionCount = attrs.vote.length; const selectedOptionCount = attrs.vote.length;
if (attrs.isMultiple) { if (attrs.isMultiple) {
return selectedOptionCount >= this.min() && selectedOptionCount <= this.max(); return selectedOptionCount >= this.min() && selectedOptionCount <= this.max();
} }
return selectedOptionCount > 0; return selectedOptionCount > 0;
}, },
toggleStatus() { toggleStatus() {
const { state, attrs } = this; const { state, attrs } = this;
const { poll } = attrs; const { post, poll } = attrs;
const isClosed = poll.get('status') === 'closed';
if (this.isAutomaticallyClosed()) { return; }
bootbox.confirm( bootbox.confirm(
I18n.t(isClosed ? "poll.open.confirm" : "poll.close.confirm"), I18n.t(this.isClosed() ? "poll.open.confirm" : "poll.close.confirm"),
I18n.t("no_value"), I18n.t("no_value"),
I18n.t("yes_value"), I18n.t("yes_value"),
confirmed => { confirmed => {
if (confirmed) { if (confirmed) {
state.loading = true; state.loading = true;
const status = this.isClosed() ? "open" : "closed";
const status = isClosed ? "open" : "closed";
ajax("/polls/toggle_status", { ajax("/polls/toggle_status", {
type: "PUT", type: "PUT",
data: { data: {
post_id: attrs.post.get('id'), post_id: post.get('id'),
poll_name: poll.get('name'), poll_name: poll.get('name'),
status, status,
} }
@ -535,15 +555,15 @@ export default createWidget('discourse-poll', {
}, },
showLogin() { showLogin() {
const appRoute = this.register.lookup('route:application'); this.register.lookup('route:application').send('showLogin');
appRoute.send('showLogin');
}, },
toggleOption(option) { toggleOption(option) {
const { attrs } = this;
if (this.isClosed()) { return; } if (this.isClosed()) { return; }
if (!this.currentUser) { this.showLogin(); } if (!this.currentUser) { this.showLogin(); }
const { attrs } = this;
const { vote } = attrs; const { vote } = attrs;
const chosenIdx = vote.indexOf(option.id); const chosenIdx = vote.indexOf(option.id);

View File

@ -52,9 +52,14 @@ div.poll {
font-size: 3.5em; font-size: 3.5em;
} }
.info-text { .info-label {
font-size: 1.7em; font-size: 1.7em;
} }
.info-text {
margin: 5px 0;
display: block;
}
} }
.poll-container { .poll-container {

View File

@ -14,7 +14,7 @@ div.poll {
margin: 40px 20px; margin: 40px 20px;
} }
.info-text { .info-label {
display: block; display: block;
} }
} }

View File

@ -8,6 +8,8 @@ div.poll {
} }
.poll-info { .poll-info {
.info-text:before {content:"\00a0";} // nbsp .info-label:before {
content:"\00a0"; //nbsp
}
} }
} }

View File

@ -65,6 +65,9 @@ en:
label: "Close" label: "Close"
confirm: "Are you sure you want to close this poll?" confirm: "Are you sure you want to close this poll?"
automatic_close:
closes_in: "Closes in <strong>%{timeLeft}</strong>."
error_while_toggling_status: "Sorry, there was an error toggling the status of this poll." error_while_toggling_status: "Sorry, there was an error toggling the status of this poll."
error_while_casting_votes: "Sorry, there was an error casting your votes." error_while_casting_votes: "Sorry, there was an error casting your votes."
error_while_fetching_voters: "Sorry, there was an error displaying the voters." error_while_fetching_voters: "Sorry, there was an error displaying the voters."
@ -89,3 +92,5 @@ en:
label: Show who voted label: Show who voted
poll_options: poll_options:
label: Enter one poll option per line label: Enter one poll option per line
automatic_close:
label: Automatically close poll

View File

@ -44,7 +44,7 @@ en:
requires_at_least_1_valid_option: "You must select at least 1 valid option." requires_at_least_1_valid_option: "You must select at least 1 valid option."
default_cannot_be_made_public: "Poll with votes cannot be made public." default_cannot_be_made_public: "Poll with votes cannot be made public."
named_cannot_be_made_public: "Poll named <strong>%{name}</strong> has votes cannot be made public." named_cannot_be_made_public: "Poll named <strong>%{name}</strong> has votes and cannot be made public."
edit_window_expired: edit_window_expired:
op_cannot_edit_options: "You cannot add or remove poll options after the first %{minutes} minutes. Please contact a moderator if you need to edit a poll option." 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."

View File

@ -0,0 +1,11 @@
module Jobs
class ClosePoll < Jobs::Base
def execute(args)
DiscoursePoll::Poll.toggle_status(args[:post_id], args[:poll_name], "closed", -1)
end
end
end

View File

@ -1,6 +1,6 @@
module DiscoursePoll module DiscoursePoll
class PollsUpdater class PollsUpdater
VALID_POLLS_CONFIGS = %w{type min max public}.map(&:freeze) VALID_POLLS_CONFIGS = %w{type min max public close}.map(&:freeze)
def self.update(post, polls) def self.update(post, polls)
# load previous polls # load previous polls
@ -18,28 +18,18 @@ module DiscoursePoll
poll_edit_window_mins = SiteSetting.poll_edit_window_mins poll_edit_window_mins = SiteSetting.poll_edit_window_mins
if post.created_at < poll_edit_window_mins.minutes.ago && has_votes if post.created_at < poll_edit_window_mins.minutes.ago && has_votes
is_staff = User.staff.where(id: post.last_editor_id).exists?
# deal with option changes # deal with option changes
if is_staff if User.staff.where(id: post.last_editor_id).exists?
# staff can edit options # staff can edit options
polls.each_key do |poll_name| 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 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( post.errors.add(:base, I18n.t("poll.edit_window_expired.staff_cannot_add_or_remove_options", minutes: poll_edit_window_mins))
"poll.edit_window_expired.staff_cannot_add_or_remove_options",
minutes: poll_edit_window_mins
))
return return
end end
end end
else else
# OP cannot edit poll options # OP cannot edit poll options
post.errors.add(:base, I18n.t( post.errors.add(:base, I18n.t("poll.edit_window_expired.op_cannot_edit_options", minutes: poll_edit_window_mins))
"poll.edit_window_expired.op_cannot_edit_options",
minutes: poll_edit_window_mins
))
return return
end end
end end
@ -93,6 +83,9 @@ module DiscoursePoll
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true) post.save_custom_fields(true)
# re-schedule jobs
DiscoursePoll::Poll.schedule_jobs(post)
# publish the changes # publish the changes
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls) MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
end end
@ -102,9 +95,7 @@ module DiscoursePoll
return true if (current_polls.keys.sort != previous_polls.keys.sort) return true if (current_polls.keys.sort != previous_polls.keys.sort)
current_polls.each_key do |poll_name| current_polls.each_key do |poll_name|
if !previous_polls[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))
(current_polls[poll_name].values_at(*VALID_POLLS_CONFIGS) != previous_polls[poll_name].values_at(*VALID_POLLS_CONFIGS))
return true return true
end end
end end
@ -127,12 +118,9 @@ module DiscoursePoll
current_poll = current_polls[poll_name] current_poll = current_polls[poll_name]
if previous_polls["public"].nil? && current_poll["public"] == "true" if previous_polls["public"].nil? && current_poll["public"] == "true"
error = error = poll_name == DiscoursePoll::DEFAULT_POLL_NAME ?
if poll_name == DiscoursePoll::DEFAULT_POLL_NAME I18n.t("poll.default_cannot_be_made_public") :
I18n.t("poll.default_cannot_be_made_public") I18n.t("poll.named_cannot_be_made_public", name: poll_name)
else
I18n.t("poll.named_cannot_be_made_public", name: poll_name)
end
post.errors.add(:base, error) post.errors.add(:base, error)
return true return true

View File

@ -6,17 +6,13 @@ module DiscoursePoll
def validate_post def validate_post
min_trust_level = SiteSetting.poll_minimum_trust_level_to_create min_trust_level = SiteSetting.poll_minimum_trust_level_to_create
trusted = @post&.user&.staff? ||
@post&.user&.trust_level >= TrustLevel[min_trust_level]
if !trusted if @post&.user&.staff? || @post&.user&.trust_level >= TrustLevel[min_trust_level]
message = I18n.t("poll.insufficient_rights_to_create") true
else
@post.errors.add(:base, message) @post.errors.add(:base, I18n.t("poll.insufficient_rights_to_create"))
return false false
end end
true
end end
end end
end end

View File

@ -10,20 +10,21 @@ register_asset "stylesheets/desktop/poll.scss", :desktop
register_asset "stylesheets/mobile/poll.scss", :mobile register_asset "stylesheets/mobile/poll.scss", :mobile
PLUGIN_NAME ||= "discourse_poll".freeze PLUGIN_NAME ||= "discourse_poll".freeze
DATA_PREFIX ||= "data-poll-".freeze DATA_PREFIX ||= "data-poll-".freeze
after_initialize do after_initialize do
require File.expand_path("../jobs/regular/close_poll", __FILE__)
module ::DiscoursePoll module ::DiscoursePoll
DEFAULT_POLL_NAME ||= "poll".freeze DEFAULT_POLL_NAME ||= "poll".freeze
POLLS_CUSTOM_FIELD ||= "polls".freeze POLLS_CUSTOM_FIELD ||= "polls".freeze
VOTES_CUSTOM_FIELD ||= "polls-votes".freeze VOTES_CUSTOM_FIELD ||= "polls-votes".freeze
MUTEX_PREFIX ||= PLUGIN_NAME
autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator" autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator"
autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator" autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator"
autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater" autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater"
autoload :VotesUpdater, "#{Rails.root}/plugins/poll/lib/votes_updater" autoload :VotesUpdater, "#{Rails.root}/plugins/poll/lib/votes_updater"
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
engine_name PLUGIN_NAME engine_name PLUGIN_NAME
@ -62,7 +63,12 @@ after_initialize do
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank? 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" raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open"
public_poll = (poll["public"] == "true")
# ensure no race condition when poll is automatically closed
if poll["close"].present?
close_date = Time.zone.parse(poll["close"]) rescue nil
raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if close_date && close_date <= Time.zone.now
end
# remove options that aren't available in the poll # remove options that aren't available in the poll
available_options = poll["options"].map { |o| o["id"] }.to_set available_options = poll["options"].map { |o| o["id"] }.to_set
@ -83,6 +89,8 @@ after_initialize do
poll["voters"] += 1 if (available_options & votes.to_set).size > 0 poll["voters"] += 1 if (available_options & votes.to_set).size > 0
end end
public_poll = (poll["public"] == "true")
poll["options"].each do |option| poll["options"].each do |option|
anonymous_votes = option["anonymous_votes"] || 0 anonymous_votes = option["anonymous_votes"] || 0
option["votes"] = all_options[option["id"]] + anonymous_votes option["votes"] = all_options[option["id"]] + anonymous_votes
@ -102,14 +110,11 @@ after_initialize do
post.save_custom_fields(true) post.save_custom_fields(true)
payload = { post_id: post_id, polls: polls } payload = { post_id: post_id, polls: polls }
payload.merge!(user: UserNameSerializer.new(user).serializable_hash) if public_poll
if public_poll
payload.merge!(user: UserNameSerializer.new(user).serializable_hash)
end
MessageBus.publish("/polls/#{post.topic_id}", payload) MessageBus.publish("/polls/#{post.topic_id}", payload)
return [poll, options] [poll, options]
end end
end end
@ -149,36 +154,40 @@ after_initialize do
end end
end 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)
if poll["status"] == "open" && poll["close"].present?
close_date = Time.zone.parse(poll["close"]) rescue nil
Jobs.enqueue_at(close_date, :close_poll, post_id: post.id, poll_name: name) if close_date && close_date > Time.zone.now
end
end
end
def extract(raw, topic_id, user_id = nil) def extract(raw, topic_id, user_id = nil)
# TODO: we should fix the callback mess so that the cooked version is available # TODO: we should fix the callback mess so that the cooked version is available
# in the validators instead of cooking twice # in the validators instead of cooking twice
cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id) cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id)
parsed = Nokogiri::HTML(cooked)
extracted_polls = [] Nokogiri::HTML(cooked).css("div.poll").map do |p|
# extract polls
parsed.css("div.poll").each do |p|
poll = { "options" => [], "voters" => 0 } poll = { "options" => [], "voters" => 0 }
# extract attributes # attributes
p.attributes.values.each do |attribute| p.attributes.values.each do |attribute|
if attribute.name.start_with?(DATA_PREFIX) if attribute.name.start_with?(DATA_PREFIX)
poll[attribute.name[DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "") poll[attribute.name[DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "")
end end
end end
# extract options # options
p.css("li[#{DATA_PREFIX}option-id]").each do |o| p.css("li[#{DATA_PREFIX}option-id]").each do |o|
option_id = o.attributes[DATA_PREFIX + "option-id"].value || "" option_id = o.attributes[DATA_PREFIX + "option-id"].value || ""
poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 } poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 }
end end
# add the poll poll
extracted_polls << poll
end end
extracted_polls
end end
end end
end end
@ -228,14 +237,13 @@ after_initialize do
raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll raise Discourse::InvalidParameters.new("poll_name is invalid") if !poll
voter_limit = (params[:voter_limit] || 25).to_i voter_limit = (params[:voter_limit] || 25).to_i
voter_limit = 0 if voter_limit < 0 voter_limit = 0 if voter_limit < 0
voter_limit = 50 if voter_limit > 50 voter_limit = 50 if voter_limit > 50
user_ids = [] user_ids = []
options = poll["options"] options = poll["options"]
if poll["type"] != "number" if poll["type"] != "number"
per_option_voters = {} per_option_voters = {}
options.each do |option| options.each do |option|
@ -256,7 +264,7 @@ after_initialize do
result = {} result = {}
User.where(id: user_ids).map do |user| User.where(id: user_ids).each do |user|
user_hash = UserNameSerializer.new(user).serializable_hash user_hash = UserNameSerializer.new(user).serializable_hash
poll_votes[user.id.to_s][poll_name].each do |option_id| poll_votes[user.id.to_s][poll_name].each do |option_id|
@ -277,12 +285,7 @@ after_initialize do
user_ids.flatten! user_ids.flatten!
user_ids.uniq! user_ids.uniq!
user_ids = user_ids.slice((params[:offset].to_i || 0) * voter_limit, voter_limit) 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 }
result = []
User.where(id: user_ids).map do |user|
result << UserNameSerializer.new(user).serializable_hash
end
end end
render json: { poll_name => result } render json: { poll_name => result }
@ -373,18 +376,19 @@ after_initialize do
if post.nil? || post.trashed? if post.nil? || post.trashed?
fragment.css(".poll, [data-poll-name]").each(&:remove) fragment.css(".poll, [data-poll-name]").each(&:remove)
else else
post_url = "#{Discourse.base_url}#{post.url}" post_url = post.full_url
fragment.css(".poll, [data-poll-name]").each do |poll| fragment.css(".poll, [data-poll-name]").each do |poll|
poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>" poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
end end
end end
end end
# tells the front-end we have a poll for that post
on(:post_created) do |post| on(:post_created) do |post|
next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank? next if post.is_first_post? || post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].blank?
MessageBus.publish("/polls/#{post.topic_id}", post_id: post.id, # signals the front-end we have polls for that post
polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) 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)
end end
on(:merging_users) do |source_user, target_user| on(:merging_users) do |source_user, target_user|