Add polls (#10111)
* Add polls Fix #1629 * Add tests * Fixes * Change API for creating polls * Use name instead of content for votes * Remove poll validation for remote polls * Add polls to public pages * When updating the poll, update options just in case they were changed * Fix public pages showing both poll and other mediamaster
parent
99dc212ae5
commit
230a012f00
@ -0,0 +1,29 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::Polls::VotesController < Api::BaseController |
||||
include Authorization |
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' } |
||||
before_action :require_user! |
||||
before_action :set_poll |
||||
|
||||
respond_to :json |
||||
|
||||
def create |
||||
VoteService.new.call(current_account, @poll, vote_params[:choices]) |
||||
render json: @poll, serializer: REST::PollSerializer |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_poll |
||||
@poll = Poll.attached.find(params[:poll_id]) |
||||
authorize @poll.status, :show? |
||||
rescue Mastodon::NotPermittedError |
||||
raise ActiveRecord::RecordNotFound |
||||
end |
||||
|
||||
def vote_params |
||||
params.permit(choices: []) |
||||
end |
||||
end |
@ -0,0 +1,13 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::PollsController < Api::BaseController |
||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show |
||||
|
||||
respond_to :json |
||||
|
||||
def show |
||||
@poll = Poll.attached.find(params[:id]) |
||||
ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale? |
||||
render json: @poll, serializer: REST::PollSerializer, include_results: true |
||||
end |
||||
end |
@ -0,0 +1,53 @@ |
||||
import api from '../api'; |
||||
|
||||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; |
||||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; |
||||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; |
||||
|
||||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; |
||||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; |
||||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; |
||||
|
||||
export const vote = (pollId, choices) => (dispatch, getState) => { |
||||
dispatch(voteRequest()); |
||||
|
||||
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) |
||||
.then(({ data }) => dispatch(voteSuccess(data))) |
||||
.catch(err => dispatch(voteFail(err))); |
||||
}; |
||||
|
||||
export const fetchPoll = pollId => (dispatch, getState) => { |
||||
dispatch(fetchPollRequest()); |
||||
|
||||
api(getState).get(`/api/v1/polls/${pollId}`) |
||||
.then(({ data }) => dispatch(fetchPollSuccess(data))) |
||||
.catch(err => dispatch(fetchPollFail(err))); |
||||
}; |
||||
|
||||
export const voteRequest = () => ({ |
||||
type: POLL_VOTE_REQUEST, |
||||
}); |
||||
|
||||
export const voteSuccess = poll => ({ |
||||
type: POLL_VOTE_SUCCESS, |
||||
poll, |
||||
}); |
||||
|
||||
export const voteFail = error => ({ |
||||
type: POLL_VOTE_FAIL, |
||||
error, |
||||
}); |
||||
|
||||
export const fetchPollRequest = () => ({ |
||||
type: POLL_FETCH_REQUEST, |
||||
}); |
||||
|
||||
export const fetchPollSuccess = poll => ({ |
||||
type: POLL_FETCH_SUCCESS, |
||||
poll, |
||||
}); |
||||
|
||||
export const fetchPollFail = error => ({ |
||||
type: POLL_FETCH_FAIL, |
||||
error, |
||||
}); |
@ -0,0 +1,144 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import classNames from 'classnames'; |
||||
import { vote, fetchPoll } from 'mastodon/actions/polls'; |
||||
import Motion from 'mastodon/features/ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
const messages = defineMessages({ |
||||
moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, |
||||
seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, |
||||
minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, |
||||
hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, |
||||
days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, |
||||
}); |
||||
|
||||
const SECOND = 1000; |
||||
const MINUTE = 1000 * 60; |
||||
const HOUR = 1000 * 60 * 60; |
||||
const DAY = 1000 * 60 * 60 * 24; |
||||
|
||||
const timeRemainingString = (intl, date, now) => { |
||||
const delta = date.getTime() - now; |
||||
|
||||
let relativeTime; |
||||
|
||||
if (delta < 10 * SECOND) { |
||||
relativeTime = intl.formatMessage(messages.moments); |
||||
} else if (delta < MINUTE) { |
||||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); |
||||
} else if (delta < HOUR) { |
||||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); |
||||
} else if (delta < DAY) { |
||||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); |
||||
} else { |
||||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); |
||||
} |
||||
|
||||
return relativeTime; |
||||
}; |
||||
|
||||
export default @injectIntl |
||||
class Poll extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
poll: ImmutablePropTypes.map.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
dispatch: PropTypes.func, |
||||
disabled: PropTypes.bool, |
||||
}; |
||||
|
||||
state = { |
||||
selected: {}, |
||||
}; |
||||
|
||||
handleOptionChange = e => { |
||||
const { target: { value } } = e; |
||||
|
||||
if (this.props.poll.get('multiple')) { |
||||
const tmp = { ...this.state.selected }; |
||||
tmp[value] = true; |
||||
this.setState({ selected: tmp }); |
||||
} else { |
||||
const tmp = {}; |
||||
tmp[value] = true; |
||||
this.setState({ selected: tmp }); |
||||
} |
||||
}; |
||||
|
||||
handleVote = () => { |
||||
if (this.props.disabled) { |
||||
return; |
||||
} |
||||
|
||||
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); |
||||
}; |
||||
|
||||
handleRefresh = () => { |
||||
if (this.props.disabled) { |
||||
return; |
||||
} |
||||
|
||||
this.props.dispatch(fetchPoll(this.props.poll.get('id'))); |
||||
}; |
||||
|
||||
renderOption (option, optionIndex) { |
||||
const { poll } = this.props; |
||||
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100; |
||||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); |
||||
const active = !!this.state.selected[`${optionIndex}`]; |
||||
const showResults = poll.get('voted') || poll.get('expired'); |
||||
|
||||
return ( |
||||
<li key={option.get('title')}> |
||||
{showResults && ( |
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> |
||||
{({ width }) => |
||||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> |
||||
} |
||||
</Motion> |
||||
)} |
||||
|
||||
<label className={classNames('poll__text', { selectable: !showResults })}> |
||||
<input |
||||
name='vote-options' |
||||
type={poll.get('multiple') ? 'checkbox' : 'radio'} |
||||
value={optionIndex} |
||||
checked={active} |
||||
onChange={this.handleOptionChange} |
||||
/> |
||||
|
||||
{!showResults && <span className={classNames('poll__input', { active })} />} |
||||
{showResults && <span className='poll__number'>{Math.floor(percent)}%</span>} |
||||
|
||||
{option.get('title')} |
||||
</label> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
render () { |
||||
const { poll, intl } = this.props; |
||||
const timeRemaining = timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now()); |
||||
const showResults = poll.get('voted') || poll.get('expired'); |
||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); |
||||
|
||||
return ( |
||||
<div className='poll'> |
||||
<ul> |
||||
{poll.get('options').map((option, i) => this.renderOption(option, i))} |
||||
</ul> |
||||
|
||||
<div className='poll__footer'> |
||||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} |
||||
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} |
||||
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> · {timeRemaining} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,8 @@ |
||||
import { connect } from 'react-redux'; |
||||
import Poll from 'mastodon/components/poll'; |
||||
|
||||
const mapStateToProps = (state, { pollId }) => ({ |
||||
poll: state.getIn(['polls', pollId]), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps)(Poll); |
@ -0,0 +1,19 @@ |
||||
import { POLL_VOTE_SUCCESS, POLL_FETCH_SUCCESS } from 'mastodon/actions/polls'; |
||||
import { POLLS_IMPORT } from 'mastodon/actions/importer'; |
||||
import { Map as ImmutableMap, fromJS } from 'immutable'; |
||||
|
||||
const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); |
||||
|
||||
const initialState = ImmutableMap(); |
||||
|
||||
export default function polls(state = initialState, action) { |
||||
switch(action.type) { |
||||
case POLLS_IMPORT: |
||||
return importPolls(state, action.polls); |
||||
case POLL_VOTE_SUCCESS: |
||||
case POLL_FETCH_SUCCESS: |
||||
return importPolls(state, [action.poll]); |
||||
default: |
||||
return state; |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
.poll { |
||||
margin-top: 16px; |
||||
font-size: 14px; |
||||
|
||||
li { |
||||
margin-bottom: 10px; |
||||
position: relative; |
||||
} |
||||
|
||||
&__chart { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
height: 100%; |
||||
display: inline-block; |
||||
border-radius: 4px; |
||||
background: darken($ui-primary-color, 14%); |
||||
|
||||
&.leading { |
||||
background: $ui-highlight-color; |
||||
} |
||||
} |
||||
|
||||
&__text { |
||||
position: relative; |
||||
display: inline-block; |
||||
padding: 6px 0; |
||||
line-height: 18px; |
||||
cursor: default; |
||||
|
||||
input[type=radio], |
||||
input[type=checkbox] { |
||||
display: none; |
||||
} |
||||
|
||||
&.selectable { |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
|
||||
&__input { |
||||
display: inline-block; |
||||
position: relative; |
||||
border: 1px solid $ui-primary-color; |
||||
box-sizing: border-box; |
||||
width: 18px; |
||||
height: 18px; |
||||
margin-right: 10px; |
||||
top: -1px; |
||||
border-radius: 4px; |
||||
vertical-align: middle; |
||||
|
||||
&.active { |
||||
border-color: $valid-value-color; |
||||
background: $valid-value-color; |
||||
} |
||||
} |
||||
|
||||
&__number { |
||||
display: inline-block; |
||||
width: 36px; |
||||
font-weight: 700; |
||||
padding: 0 10px; |
||||
text-align: right; |
||||
} |
||||
|
||||
&__footer { |
||||
padding-top: 6px; |
||||
padding-bottom: 5px; |
||||
color: $dark-text-color; |
||||
} |
||||
|
||||
&__link { |
||||
display: inline; |
||||
background: transparent; |
||||
padding: 0; |
||||
margin: 0; |
||||
border: 0; |
||||
color: $dark-text-color; |
||||
text-decoration: underline; |
||||
|
||||
&:hover, |
||||
&:focus, |
||||
&:active { |
||||
text-decoration: none; |
||||
} |
||||
} |
||||
|
||||
.button { |
||||
height: 36px; |
||||
padding: 0 16px; |
||||
margin-right: 10px; |
||||
font-size: 14px; |
||||
} |
||||
} |
@ -0,0 +1,90 @@ |
||||
# frozen_string_literal: true |
||||
# == Schema Information |
||||
# |
||||
# Table name: polls |
||||
# |
||||
# id :bigint(8) not null, primary key |
||||
# account_id :bigint(8) |
||||
# status_id :bigint(8) |
||||
# expires_at :datetime |
||||
# options :string default([]), not null, is an Array |
||||
# cached_tallies :bigint(8) default([]), not null, is an Array |
||||
# multiple :boolean default(FALSE), not null |
||||
# hide_totals :boolean default(FALSE), not null |
||||
# votes_count :bigint(8) default(0), not null |
||||
# last_fetched_at :datetime |
||||
# created_at :datetime not null |
||||
# updated_at :datetime not null |
||||
# |
||||
|
||||
class Poll < ApplicationRecord |
||||
include Expireable |
||||
|
||||
belongs_to :account |
||||
belongs_to :status |
||||
|
||||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy |
||||
|
||||
validates :options, presence: true |
||||
validates :expires_at, presence: true, if: :local? |
||||
validates_with PollValidator, if: :local? |
||||
|
||||
scope :attached, -> { where.not(status_id: nil) } |
||||
scope :unattached, -> { where(status_id: nil) } |
||||
|
||||
before_validation :prepare_votes_count |
||||
after_initialize :prepare_cached_tallies |
||||
after_commit :reset_parent_cache, on: :update |
||||
|
||||
def loaded_options |
||||
options.map.with_index { |title, key| Option.new(self, key.to_s, title, cached_tallies[key]) } |
||||
end |
||||
|
||||
def unloaded_options |
||||
options.map.with_index { |title, key| Option.new(self, key.to_s, title, nil) } |
||||
end |
||||
|
||||
def possibly_stale? |
||||
remote? && last_fetched_before_expiration? && time_passed_since_last_fetch? |
||||
end |
||||
|
||||
delegate :local?, to: :account |
||||
|
||||
def remote? |
||||
!local? |
||||
end |
||||
|
||||
class Option < ActiveModelSerializers::Model |
||||
attributes :id, :title, :votes_count, :poll |
||||
|
||||
def initialize(poll, id, title, votes_count) |
||||
@poll = poll |
||||
@id = id |
||||
@title = title |
||||
@votes_count = votes_count |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
def prepare_cached_tallies |
||||
self.cached_tallies = options.map { 0 } if cached_tallies.empty? |
||||
end |
||||
|
||||
def prepare_votes_count |
||||
self.votes_count = cached_tallies.sum unless cached_tallies.empty? |
||||
end |
||||
|
||||
def reset_parent_cache |
||||
return if status_id.nil? |
||||
Rails.cache.delete("statuses/#{status_id}") |
||||
end |
||||
|
||||
def last_fetched_before_expiration? |
||||
last_fetched_at.nil? || expires_at.nil? || last_fetched_at < expires_at |
||||
end |
||||
|
||||
def time_passed_since_last_fetch? |
||||
last_fetched_at.nil? || last_fetched_at < 1.minute.ago |
||||
end |
||||
end |
@ -0,0 +1,29 @@ |
||||
# frozen_string_literal: true |
||||
# == Schema Information |
||||
# |
||||
# Table name: poll_votes |
||||
# |
||||
# id :bigint(8) not null, primary key |
||||
# account_id :bigint(8) |
||||
# poll_id :bigint(8) |
||||
# choice :integer default(0), not null |
||||
# created_at :datetime not null |
||||
# updated_at :datetime not null |
||||
# |
||||
|
||||
class PollVote < ApplicationRecord |
||||
belongs_to :account |
||||
belongs_to :poll, inverse_of: :votes |
||||
|
||||
validates :choice, presence: true |
||||
validates_with VoteValidator |
||||
|
||||
after_create_commit :increment_counter_cache |
||||
|
||||
private |
||||
|
||||
def increment_counter_cache |
||||
poll.cached_tallies[choice] = (poll.cached_tallies[choice] || 0) + 1 |
||||
poll.save |
||||
end |
||||
end |
@ -0,0 +1,7 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class PollPolicy < ApplicationPolicy |
||||
def vote? |
||||
!current_account.blocking?(record.account) && !record.account.blocking?(current_account) |
||||
end |
||||
end |
@ -0,0 +1,48 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class ActivityPub::VoteSerializer < ActiveModel::Serializer |
||||
class NoteSerializer < ActiveModel::Serializer |
||||
attributes :id, :type, :name, :attributed_to, |
||||
:in_reply_to, :to |
||||
|
||||
def id |
||||
nil |
||||
end |
||||
|
||||
def type |
||||
'Note' |
||||
end |
||||
|
||||
def name |
||||
object.poll.options[object.choice.to_i] |
||||
end |
||||
|
||||
def attributed_to |
||||
ActivityPub::TagManager.instance.uri_for(object.account) |
||||
end |
||||
|
||||
def to |
||||
ActivityPub::TagManager.instance.uri_for(object.poll.account) |
||||
end |
||||
end |
||||
|
||||
attributes :id, :type, :actor, :to |
||||
|
||||
has_one :object, serializer: ActivityPub::VoteSerializer::NoteSerializer |
||||
|
||||
def id |
||||
nil |
||||
end |
||||
|
||||
def type |
||||
'Create' |
||||
end |
||||
|
||||
def actor |
||||
ActivityPub::TagManager.instance.uri_for(object.account) |
||||
end |
||||
|
||||
def to |
||||
ActivityPub::TagManager.instance.uri_for(object.poll.account) |
||||
end |
||||
end |
@ -0,0 +1,38 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class REST::PollSerializer < ActiveModel::Serializer |
||||
attributes :id, :expires_at, :expired, |
||||
:multiple, :votes_count |
||||
|
||||
has_many :dynamic_options, key: :options |
||||
|
||||
attribute :voted, if: :current_user? |
||||
|
||||
def id |
||||
object.id.to_s |
||||
end |
||||
|
||||
def dynamic_options |
||||
if !object.expired? && object.hide_totals? |
||||
object.unloaded_options |
||||
else |
||||
object.loaded_options |
||||
end |
||||
end |
||||
|
||||
def expired |
||||
object.expired? |
||||
end |
||||
|
||||
def voted |
||||
object.votes.where(account: current_user.account).exists? |
||||
end |
||||
|
||||
def current_user? |
||||
!current_user.nil? |
||||
end |
||||
|
||||
class OptionSerializer < ActiveModel::Serializer |
||||
attributes :title, :votes_count |
||||
end |
||||
end |
@ -0,0 +1,51 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class ActivityPub::FetchRemotePollService < BaseService |
||||
include JsonLdHelper |
||||
|
||||
def call(poll, on_behalf_of = nil) |
||||
@json = fetch_resource(poll.status.uri, true, on_behalf_of) |
||||
|
||||
return unless supported_context? && expected_type? |
||||
|
||||
expires_at = begin |
||||
if @json['closed'].is_a?(String) |
||||
@json['closed'] |
||||
elsif !@json['closed'].is_a?(FalseClass) |
||||
Time.now.utc |
||||
else |
||||
@json['endTime'] |
||||
end |
||||
end |
||||
|
||||
items = begin |
||||
if @json['anyOf'].is_a?(Array) |
||||
@json['anyOf'] |
||||
else |
||||
@json['oneOf'] |
||||
end |
||||
end |
||||
|
||||
latest_options = items.map { |item| item['name'].presence || item['content'] } |
||||
|
||||
# If for some reasons the options were changed, it invalidates all previous |
||||
# votes, so we need to remove them |
||||
poll.votes.delete_all if latest_options != poll.options |
||||
|
||||
poll.update!( |
||||
expires_at: expires_at, |
||||
options: latest_options, |
||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } |
||||
) |
||||
end |
||||
|
||||
private |
||||
|
||||
def supported_context? |
||||
super(@json) |
||||
end |
||||
|
||||
def expected_type? |
||||
equals_or_includes_any?(@json['type'], 'Question') |
||||
end |
||||
end |
@ -0,0 +1,40 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class VoteService < BaseService |
||||
include Authorization |
||||
|
||||
def call(account, poll, choices) |
||||
authorize_with account, poll, :vote? |
||||
|
||||
@account = account |
||||
@poll = poll |
||||
@choices = choices |
||||
@votes = [] |
||||
|
||||
ApplicationRecord.transaction do |
||||
@choices.each do |choice| |
||||
@votes << @poll.votes.create!(account: @account, choice: choice) |
||||
end |
||||
end |
||||
|
||||
return if @poll.account.local? |
||||
|
||||
@votes.each do |vote| |
||||
ActivityPub::DeliveryWorker.perform_async( |
||||
build_json(vote), |
||||
@account.id, |
||||
@poll.account.inbox_url |
||||
) |
||||
end |
||||
end |
||||
|
||||
private |
||||
|
||||
def build_json(vote) |
||||
ActiveModelSerializers::SerializableResource.new( |
||||
vote, |
||||
serializer: ActivityPub::VoteSerializer, |
||||
adapter: ActivityPub::Adapter |
||||
).to_json |
||||
end |
||||
end |
@ -0,0 +1,19 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class PollValidator < ActiveModel::Validator |
||||
MAX_OPTIONS = 4 |
||||
MAX_OPTION_CHARS = 25 |
||||
MAX_EXPIRATION = 7.days.freeze |
||||
MIN_EXPIRATION = 1.day.freeze |
||||
|
||||
def validate(poll) |
||||
current_time = Time.now.utc |
||||
|
||||
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 |
||||
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS |
||||
poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } |
||||
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size |
||||
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time >= MAX_EXPIRATION |
||||
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && poll.expires_at - current_time <= MIN_EXPIRATION |
||||
end |
||||
end |
@ -0,0 +1,13 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class VoteValidator < ActiveModel::Validator |
||||
def validate(vote) |
||||
vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll.expired? |
||||
|
||||
if vote.poll.multiple? && vote.poll.votes.where(account: vote.account, choice: vote.choice).exists? |
||||
vote.errors.add(:base, I18n.t('polls.errors.already_voted')) |
||||
elsif vote.poll.votes.where(account: vote.account).exists? |
||||
vote.errors.add(:base, I18n.t('polls.errors.already_voted')) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,17 @@ |
||||
class CreatePolls < ActiveRecord::Migration[5.2] |
||||
def change |
||||
create_table :polls do |t| |
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade } |
||||
t.belongs_to :status, foreign_key: { on_delete: :cascade } |
||||
t.datetime :expires_at |
||||
t.string :options, null: false, array: true, default: [] |
||||
t.bigint :cached_tallies, null: false, array: true, default: [] |
||||
t.boolean :multiple, null: false, default: false |
||||
t.boolean :hide_totals, null: false, default: false |
||||
t.bigint :votes_count, null: false, default: 0 |
||||
t.datetime :last_fetched_at |
||||
|
||||
t.timestamps |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,11 @@ |
||||
class CreatePollVotes < ActiveRecord::Migration[5.2] |
||||
def change |
||||
create_table :poll_votes do |t| |
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade } |
||||
t.belongs_to :poll, foreign_key: { on_delete: :cascade } |
||||
t.integer :choice, null: false, default: 0 |
||||
|
||||
t.timestamps |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,5 @@ |
||||
class AddPollIdToStatuses < ActiveRecord::Migration[5.2] |
||||
def change |
||||
add_column :statuses, :poll_id, :bigint |
||||
end |
||||
end |
@ -0,0 +1,34 @@ |
||||
require 'rails_helper' |
||||
|
||||
RSpec.describe Api::V1::Polls::VotesController, type: :controller do |
||||
render_views |
||||
|
||||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } |
||||
let(:scopes) { 'write:statuses' } |
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } |
||||
|
||||
before { allow(controller).to receive(:doorkeeper_token) { token } } |
||||
|
||||
describe 'POST #create' do |
||||
let(:poll) { Fabricate(:poll) } |
||||
|
||||
before do |
||||
post :create, params: { poll_id: poll.id, choices: %w(1) } |
||||
end |
||||
|
||||
it 'returns http success' do |
||||
expect(response).to have_http_status(200) |
||||
end |
||||
|
||||
it 'creates a vote' do |
||||
vote = poll.votes.where(account: user.account).first |
||||
|
||||
expect(vote).to_not be_nil |
||||
expect(vote.choice).to eq 1 |
||||
end |
||||
|
||||
it 'updates poll tallies' do |
||||
expect(poll.reload.cached_tallies).to eq [0, 1] |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,23 @@ |
||||
require 'rails_helper' |
||||
|
||||
RSpec.describe Api::V1::PollsController, type: :controller do |
||||
render_views |
||||
|
||||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } |
||||
let(:scopes) { 'read:statuses' } |
||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } |
||||
|
||||
before { allow(controller).to receive(:doorkeeper_token) { token } } |
||||
|
||||
describe 'GET #show' do |
||||
let(:poll) { Fabricate(:poll) } |
||||
|
||||
before do |
||||
get :show, params: { id: poll.id } |
||||
end |
||||
|
||||
it 'returns http success' do |
||||
expect(response).to have_http_status(200) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,8 @@ |
||||
Fabricator(:poll) do |
||||
account |
||||
status |
||||
expires_at { 7.days.from_now } |
||||
options %w(Foo Bar) |
||||
multiple false |
||||
hide_totals false |
||||
end |
@ -0,0 +1,5 @@ |
||||
Fabricator(:poll_vote) do |
||||
account |
||||
poll |
||||
choice 0 |
||||
end |
@ -0,0 +1,5 @@ |
||||
require 'rails_helper' |
||||
|
||||
RSpec.describe Poll, type: :model do |
||||
pending "add some examples to (or delete) #{__FILE__}" |
||||
end |
@ -0,0 +1,5 @@ |
||||
require 'rails_helper' |
||||
|
||||
RSpec.describe PollVote, type: :model do |
||||
pending "add some examples to (or delete) #{__FILE__}" |
||||
end |
Loading…
Reference in new issue