From 48eda4ff5d7857a07dd37c440d6b024bb41eaefa Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Thu, 19 Dec 2019 22:41:19 -0500 Subject: [PATCH] poll votes --- lib/philomena/poll_options/poll_option.ex | 2 + lib/philomena/poll_votes.ex | 84 +++++++++++++++---- .../controllers/topic/poll/vote_controller.ex | 61 ++++++++++++++ .../controllers/topic_controller.ex | 6 +- lib/philomena_web/plugs/load_poll_plug.ex | 33 ++++++++ lib/philomena_web/router.ex | 1 + .../templates/topic/poll/_display.html.slime | 30 ++++--- .../topic/poll/_vote_form.html.slime | 24 ++++++ .../templates/topic/show.html.slime | 2 +- lib/philomena_web/views/topic/poll_view.ex | 7 ++ 10 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 lib/philomena_web/controllers/topic/poll/vote_controller.ex create mode 100644 lib/philomena_web/plugs/load_poll_plug.ex create mode 100644 lib/philomena_web/templates/topic/poll/_vote_form.html.slime diff --git a/lib/philomena/poll_options/poll_option.ex b/lib/philomena/poll_options/poll_option.ex index 49b64291..9b6f10ef 100644 --- a/lib/philomena/poll_options/poll_option.ex +++ b/lib/philomena/poll_options/poll_option.ex @@ -2,10 +2,12 @@ defmodule Philomena.PollOptions.PollOption do use Ecto.Schema import Ecto.Changeset + alias Philomena.PollVotes.PollVote alias Philomena.Polls.Poll schema "poll_options" do belongs_to :poll, Poll + has_many :poll_votes, PollVote field :label, :string field :vote_count, :integer, default: 0 diff --git a/lib/philomena/poll_votes.ex b/lib/philomena/poll_votes.ex index 33fc07bd..d967ebbb 100644 --- a/lib/philomena/poll_votes.ex +++ b/lib/philomena/poll_votes.ex @@ -4,22 +4,12 @@ defmodule Philomena.PollVotes do """ import Ecto.Query, warn: false + alias Ecto.Multi alias Philomena.Repo + alias Philomena.Polls.Poll alias Philomena.PollVotes.PollVote - - @doc """ - Returns the list of poll_votes. - - ## Examples - - iex> list_poll_votes() - [%PollVote{}, ...] - - """ - def list_poll_votes do - Repo.all(PollVote) - end + alias Philomena.PollOptions.PollOption @doc """ Gets a single poll_vote. @@ -49,10 +39,70 @@ defmodule Philomena.PollVotes do {:error, %Ecto.Changeset{}} """ - def create_poll_vote(attrs \\ %{}) do - %PollVote{} - |> PollVote.changeset(attrs) - |> Repo.insert() + def create_poll_votes(user, poll, attrs) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + poll_votes = filter_options(user, poll, now, attrs) + + Multi.new() + |> Multi.run(:existing_votes, fn _repo, _changes -> + # Don't proceed if any votes exist + case voted?(poll, user) do + true -> {:error, []} + _false -> {:ok, []} + end + end) + |> Multi.run(:new_votes, fn repo, _changes -> + {_count, votes} = + repo.insert_all(PollVote, poll_votes, returning: true) + + {:ok, votes} + end) + |> Multi.run(:update_option_counts, fn repo, %{new_votes: new_votes} -> + option_ids = Enum.map(new_votes, & &1.poll_option_id) + + {count, nil} = + PollOption + |> where([po], po.id in ^option_ids) + |> repo.update_all(inc: [vote_count: 1]) + + {:ok, count} + end) + |> Multi.run(:update_poll_votes_count, fn repo, %{new_votes: new_votes} -> + length = length(new_votes) + + {count, nil} = + Poll + |> where(id: ^poll.id) + |> repo.update_all(inc: [total_votes: length]) + + {:ok, count} + end) + |> Repo.isolated_transaction(:serializable) + end + + defp filter_options(user, poll, now, %{"option_ids" => options}) when is_list(options) do + # TODO: enforce integrity at the constraint level + + votes = + options + |> Enum.map(&String.to_integer/1) + |> Enum.uniq() + |> Enum.map(&%{poll_option_id: &1, user_id: user.id, created_at: now}) + + case poll.vote_method do + "single" -> Enum.take(votes, 1) + _other -> votes + end + end + defp filter_options(_user, _poll, _now, _attrs), do: [] + + def voted?(nil, _user), do: false + def voted?(_poll, nil), do: false + def voted?(%{id: poll_id}, %{id: user_id}) do + PollVote + |> join(:inner, [pv], _ in assoc(pv, :poll_option)) + |> where([pv, po], po.poll_id == ^poll_id and pv.user_id == ^user_id) + |> Repo.exists?() end @doc """ diff --git a/lib/philomena_web/controllers/topic/poll/vote_controller.ex b/lib/philomena_web/controllers/topic/poll/vote_controller.ex new file mode 100644 index 00000000..c9f18d93 --- /dev/null +++ b/lib/philomena_web/controllers/topic/poll/vote_controller.ex @@ -0,0 +1,61 @@ +defmodule PhilomenaWeb.Topic.Poll.VoteController do + use PhilomenaWeb, :controller + + alias Philomena.Forums.Forum + alias Philomena.PollOptions.PollOption + alias Philomena.PollVotes + alias Philomena.Repo + import Ecto.Query + + plug :verify_authorized when action in [:index, :delete] + plug :load_and_authorize_resource, model: Forum, id_name: "forum_id", id_field: "short_name", persisted: true + plug PhilomenaWeb.LoadTopicPlug + plug PhilomenaWeb.LoadPollPlug + + def index(conn, _params) do + poll = conn.assigns.poll + + options = + PollOption + |> where(poll_id: ^poll.id) + |> preload(poll_votes: :user) + |> Repo.all() + + render(conn, "index.html", layout: false, options: options) + end + + def create(conn, %{"poll" => poll_params}) do + poll = conn.assigns.poll + topic = conn.assigns.topic + + case PollVotes.create_poll_votes(conn.assigns.current_user, poll, poll_params) do + {:ok, _votes} -> + conn + |> put_flash(:info, "Your vote has been recorded.") + |> redirect(to: Routes.forum_topic_path(conn, :show, topic.forum, topic)) + + _error -> + conn + |> put_flash(:error, "Your vote was not recorded.") + |> redirect(to: Routes.forum_topic_path(conn, :show, topic.forum, topic)) + end + end + + def delete(conn, %{"id" => poll_vote_id}) do + topic = conn.assigns.topic + poll_vote = PollVotes.get_poll_vote!(poll_vote_id) + + {:ok, _poll_vote} = PollVotes.delete_poll_vote(poll_vote) + + conn + |> put_flash(:info, "Vote successfully removed.") + |> redirect(to: Routes.forum_topic_path(conn, :show, topic.forum, topic)) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, PollVote) do + true -> conn + _false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex index b2aab648..53232a10 100644 --- a/lib/philomena_web/controllers/topic_controller.ex +++ b/lib/philomena_web/controllers/topic_controller.ex @@ -4,6 +4,7 @@ defmodule PhilomenaWeb.TopicController do alias PhilomenaWeb.NotificationCountPlug alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption} alias Philomena.{Forums, Topics, Posts} + alias Philomena.PollVotes alias Philomena.Textile.Renderer alias Philomena.Repo import Ecto.Query @@ -68,13 +69,16 @@ defmodule PhilomenaWeb.TopicController do watching = Topics.subscribed?(topic, conn.assigns.current_user) + voted = + PollVotes.voted?(topic.poll, conn.assigns.current_user) + changeset = %Post{} |> Posts.change_post() title = "#{topic.title} - #{forum.name} - Forums" - render(conn, "show.html", title: title, posts: posts, changeset: changeset, watching: watching) + render(conn, "show.html", title: title, posts: posts, changeset: changeset, watching: watching, voted: voted) end def new(conn, _params) do diff --git a/lib/philomena_web/plugs/load_poll_plug.ex b/lib/philomena_web/plugs/load_poll_plug.ex new file mode 100644 index 00000000..143f59cc --- /dev/null +++ b/lib/philomena_web/plugs/load_poll_plug.ex @@ -0,0 +1,33 @@ +defmodule PhilomenaWeb.LoadPollPlug do + alias Philomena.Polls.Poll + alias Philomena.Repo + + import Plug.Conn, only: [assign: 3] + import Canada.Can, only: [can?: 3] + import Ecto.Query + + def init(opts), + do: opts + + def call(%{assigns: %{topic: topic}} = conn, opts) do + show_hidden = Keyword.get(opts, :show_hidden, false) + + Poll + |> where(topic_id: ^topic.id) + |> Repo.one() + |> maybe_hide_poll(conn, show_hidden) + end + + defp maybe_hide_poll(nil, conn, _show_hidden), + do: PhilomenaWeb.NotFoundPlug.call(conn) + + defp maybe_hide_poll(%{hidden_from_users: false} = poll, conn, _show_hidden), + do: assign(conn, :poll, poll) + + defp maybe_hide_poll(poll, %{assigns: %{current_user: user}} = conn, show_hidden) do + case show_hidden or can?(user, :show, poll) do + true -> assign(conn, :poll, poll) + false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 98443c82..3e5b7b3c 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -146,6 +146,7 @@ defmodule PhilomenaWeb.Router do resources "/hide", Topic.Post.HideController, only: [:create, :delete], singleton: true resources "/delete", Topic.Post.DeleteController, only: [:create], singleton: true end + resources "/poll/votes", Topic.Poll.VoteController, as: :poll_vote, only: [:index, :create, :delete] end resources "/subscription", Forum.SubscriptionController, only: [:create, :delete], singleton: true diff --git a/lib/philomena_web/templates/topic/poll/_display.html.slime b/lib/philomena_web/templates/topic/poll/_display.html.slime index 845292c1..39ddefb3 100644 --- a/lib/philomena_web/templates/topic/poll/_display.html.slime +++ b/lib/philomena_web/templates/topic/poll/_display.html.slime @@ -1,13 +1,19 @@ -= if @poll.hidden_from_users do - .walloftext - .block.block--fixed.block--warning - h1 This poll has been deleted - p - ' Reason: - strong - = @poll.deletion_reason || "Unknown (likely deleted in error). Please contact a moderator." += cond do + - @poll.hidden_from_users -> + .walloftext + .block.block--fixed.block--warning + h1 This poll has been deleted + p + ' Reason: + strong + = @poll.deletion_reason || "Unknown (likely deleted in error). Please contact a moderator." -- else - .poll - .poll-area - = render PhilomenaWeb.Topic.PollView, "_results.html", poll: @poll, conn: @conn \ No newline at end of file + - not @voted and not is_nil(@conn.assigns.current_user) -> + .poll + .poll-area + = render PhilomenaWeb.Topic.PollView, "_vote_form.html", assigns + + - true -> + .poll + .poll-area + = render PhilomenaWeb.Topic.PollView, "_results.html", assigns diff --git a/lib/philomena_web/templates/topic/poll/_vote_form.html.slime b/lib/philomena_web/templates/topic/poll/_vote_form.html.slime new file mode 100644 index 00000000..be90c184 --- /dev/null +++ b/lib/philomena_web/templates/topic/poll/_vote_form.html.slime @@ -0,0 +1,24 @@ += form_for :poll_vote, Routes.forum_topic_poll_vote_path(@conn, :create, @forum, @topic), [class: "poll-vote-form"], fn _f -> + h4.poll__header + ' Poll: + = @poll.title + + .poll-form__options + elixir: + input_type = input_type(@poll) + input_name = input_name(@poll) + require_answer? = require_answer?(@poll) + + = for option <- @poll.options do + label.poll-form__options__label + input.button--separate-right> type=input_type name=input_name value=option.id required=require_answer? + span = option.label + + .poll-form__status + p + ' Voting ends + = pretty_time(@poll.active_until) + + button.button.button--state-success> + i.fa.fa-check> + ' Vote diff --git a/lib/philomena_web/templates/topic/show.html.slime b/lib/philomena_web/templates/topic/show.html.slime index fe1ab5b0..859b1b36 100644 --- a/lib/philomena_web/templates/topic/show.html.slime +++ b/lib/philomena_web/templates/topic/show.html.slime @@ -54,7 +54,7 @@ h1 = @topic.title = if not @topic.hidden_from_users or can?(@conn, :hide, @topic) do / Display the poll, if any = if @topic.poll do - = render PhilomenaWeb.Topic.PollView, "_display.html", poll: @topic.poll, conn: @conn + = render PhilomenaWeb.Topic.PollView, "_display.html", Map.put(assigns, :poll, @topic.poll) / The actual posts .posts-area diff --git a/lib/philomena_web/views/topic/poll_view.ex b/lib/philomena_web/views/topic/poll_view.ex index 504088ee..1d7024bb 100644 --- a/lib/philomena_web/views/topic/poll_view.ex +++ b/lib/philomena_web/views/topic/poll_view.ex @@ -16,6 +16,13 @@ defmodule PhilomenaWeb.Topic.PollView do not poll.hidden_from_users and DateTime.diff(poll.active_until, DateTime.utc_now()) > 0 end + def require_answer?(%{vote_method: vote_method}), do: vote_method == "single" + + def input_type(%{vote_method: "single"}), do: "radio" + def input_type(_poll), do: "checkbox" + + def input_name(_poll), do: "poll[option_ids][]" + def percent_of_total(_option, %{total_votes: 0}), do: "0%" def percent_of_total(%{vote_count: vote_count}, %{total_votes: total_votes}) do :io_lib.format("~.2f%", [(vote_count / total_votes * 100)])