Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to close a ballot from accepting new votes #109

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
25 changes: 25 additions & 0 deletions issue-32.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
> Users should be able to close a ballot, allowing no more voting.

Currently we have two states for a ballot.

published and unpublished

this is managed by the `:published_at` field which currently can be `nil`.

Feel like we introduce a `status` field. `unpublished`, `published` and `closed`.

Remove `published_at` from being in the normal changeset. document and test this.

Introduce a new function to `close_ballot`.

Enforce state changes so there can only be one direction.

Maybe do three diferent `render` templates for the show page depending on status.

If we don't care about when a ballot was published or closed, it makes the status easier, a simple three value enum.

If we need to know its status and when the status changed we'd need to save a rich JSON value

We could also just add `closed_at` and then derive a status with a funcion.

If feels like the status of a ballot, even in this simple use is starting to outgrow being attached to the ballot directly.
74 changes: 70 additions & 4 deletions lib/flick/ranked_voting.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ defmodule Flick.RankedVoting do

@doc """
Creates a new `Flick.RankedVoting.Ballot` entity with the given `title` and `questions`.

Attempts to pass in `published_at` or `closed_at` will raise an `ArgumentError`
Please look to `published_ballot/2` and `close_ballot/2` for those lifecycle needs.
"""
@spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, Ecto.Changeset.t(Ballot.t())}
def create_ballot(attrs) when is_map(attrs) do
raise_if_attempting_to_set_published_at(attrs)
raise_if_attempting_to_set_closed_at(attrs)

%Ballot{}
|> change_ballot(attrs)
|> Repo.insert()
Expand All @@ -23,20 +29,26 @@ defmodule Flick.RankedVoting do
Updates the given `Flick.RankedVoting.Ballot` entity with the given attributes.

If the `Flick.RankedVoting.Ballot` has already been published, an error is returned.

Attempts to pass in `published_at` or `closed_at` will raise an `ArgumentError`
Please look to `published_ballot/2` and `close_ballot/2` for those lifecycle needs.
"""
@spec update_ballot(Ballot.t(), map()) ::
{:ok, Ballot.t()}
| {:error, Ecto.Changeset.t(Ballot.t())}
| {:error, :can_not_update_published_ballot}
| {:error, :can_only_update_draft_ballots}

def update_ballot(%Ballot{published_at: nil, closed_at: nil} = ballot, attrs) do
raise_if_attempting_to_set_published_at(attrs)
raise_if_attempting_to_set_closed_at(attrs)

def update_ballot(%Ballot{published_at: nil} = ballot, attrs) do
ballot
|> change_ballot(attrs)
|> Repo.update()
end

def update_ballot(_ballot, _attrs) do
{:error, :can_not_update_published_ballot}
{:error, :can_only_update_draft_ballots}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be plural if the function accepts a single ballot?

end

@doc """
Expand All @@ -61,14 +73,56 @@ defmodule Flick.RankedVoting do

def publish_ballot(%Ballot{published_at: nil} = ballot, published_at) do
ballot
|> change_ballot(%{published_at: published_at})
|> Ecto.Changeset.cast(%{published_at: published_at}, [:published_at])
|> Repo.update()
end

def publish_ballot(_ballot, _published_at) do
{:error, :ballot_already_published}
end

@spec close_ballot(Ballot.t(), DateTime.t()) ::
{:ok, Ballot.t()}
| {:error, Ecto.Changeset.t(Ballot.t())}
| {:error, :ballot_not_published}
def close_ballot(ballot, closed_at \\ DateTime.utc_now())

def close_ballot(%Ballot{published_at: nil}, _closed_at) do
{:error, :ballot_not_published}
end

def close_ballot(%Ballot{closed_at: nil} = ballot, closed_at) do
ballot
|> Ecto.Changeset.cast(%{closed_at: closed_at}, [:closed_at])
|> Repo.update()
end

def close_ballot(%Ballot{closed_at: _non_nil_value}, _closed_at) do
{:error, :ballot_already_closed}
end

@typedoc """
Represents the three states of a ballot: `:draft`, `:published`, and `:closed`.

- A `:draft` ballot can be edited, and then published.
- A `:published` ballot can no longer be updated, can accept votes and can be closed.
- A `:closed` ballot can no longer be updated, and can no longer accept votes.
"""
@type ballot_status :: :draft | :published | :closed

@doc """
Returns the `t:ballot_status/0` of the given `Flick.RankedVoting.Ballot` entity.
"""
@spec ballot_status(Ballot.t()) :: ballot_status()
def ballot_status(ballot) do
case ballot do
%Ballot{closed_at: nil, published_at: nil} -> :draft
%Ballot{closed_at: nil, published_at: _non_nil_value} -> :published
%Ballot{closed_at: _non_nil_value, published_at: nil} -> raise "invalid state observed"
%Ballot{closed_at: _non_nil_value, published_at: _another_non_nil_value} -> :closed
end
end

@doc """
Returns a list of all `Flick.RankedVoting.Ballot` entities.
"""
Expand Down Expand Up @@ -278,4 +332,16 @@ defmodule Flick.RankedVoting do

min(5, possible_answer_count)
end

defp raise_if_attempting_to_set_published_at(attrs) do
if Map.has_key?(attrs, :published_at) or Map.has_key?(attrs, "published_at") do
raise ArgumentError, "`published_at` can not be set during creation or mutation of a ballot"
end
end

defp raise_if_attempting_to_set_closed_at(attrs) do
if Map.has_key?(attrs, :closed_at) or Map.has_key?(attrs, "closed_at") do
raise ArgumentError, "`closed_at` can not be set during creation or mutation of a ballot"
end
end
end
13 changes: 11 additions & 2 deletions lib/flick/ranked_voting/ballot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ defmodule Flick.RankedVoting.Ballot do
url_slug: String.t(),
secret: Ecto.UUID.t(),
possible_answers: String.t(),
published_at: DateTime.t() | nil
published_at: DateTime.t() | nil,
closed_at: DateTime.t() | nil
}

# TODO Add `changeset` type.

@typedoc """
A type for the empty `Flick.RankedVoting.Ballot` struct.

Expand All @@ -43,11 +46,17 @@ defmodule Flick.RankedVoting.Ballot do
field :secret, :binary_id, read_after_writes: true
field :possible_answers, :string
field :published_at, :utc_datetime_usec
field :closed_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end

@required_fields [:question_title, :possible_answers, :url_slug]
@optional_fields [:published_at, :description]

# With intent, we do not allow `published_at` or `closed_at` to be set inside
# a normal changeset. Instead look to the
# `Flick.RankedVoting.publish_ballot/2` and
# `Flick.RankedVoting.close_ballot/2` to perform those updates.
@optional_fields [:description]

@spec changeset(t() | struct_t(), map()) :: Ecto.Changeset.t(t()) | Ecto.Changeset.t(struct_t())
def changeset(ballot, attrs) do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Flick.Repo.Migrations.AddClosedAtToBallots do
use Ecto.Migration

def change do
alter table(:ballots) do
add :closed_at, :utc_datetime_usec, null: true
end
end
end
43 changes: 37 additions & 6 deletions test/flick/ranked_voting_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,11 @@ defmodule Flick.RankedVotingTest do
end

test "failure: can not attempt to create a ballot that is already `published`" do
assert {:error, changeset} =
RankedVoting.create_ballot(%{published_at: ~U[2021-01-01 00:00:00Z]})

assert "new ballots can not be published" in errors_on(changeset).published_at
assert_raise ArgumentError,
"`published_at` can not be set during creation or mutation of a ballot",
fn ->
RankedVoting.create_ballot(%{published_at: ~U[2021-01-01 00:00:00Z]})
end
end

test "success: `secret` is created after row insertion" do
Expand Down Expand Up @@ -187,14 +188,21 @@ defmodule Flick.RankedVotingTest do
test "failure: can not update a published ballot" do
ballot = published_ballot_fixture()

assert {:error, :can_not_update_published_ballot} =
assert {:error, :can_only_update_draft_ballots} =
RankedVoting.update_ballot(ballot, %{title: "some new title"})
end

test "failure: can not update a closed ballot" do
ballot = closed_ballot_fixture()

assert {:error, :can_only_update_draft_ballots} =
RankedVoting.update_ballot(ballot, %{title: "some new title"})
end
end

describe "publish_ballot/2" do
test "success: you can publish a non-published ballot" do
ballot = ballot_fixture(%{published_at: nil})
ballot = ballot_fixture()
published_at = DateTime.utc_now()
assert {:ok, published_ballot} = RankedVoting.publish_ballot(ballot, published_at)
assert %Ballot{published_at: ^published_at} = published_ballot
Expand All @@ -208,6 +216,29 @@ defmodule Flick.RankedVotingTest do
end
end

describe "close_ballot/2" do
test "success: closes a published ballot" do
ballot = published_ballot_fixture()
closed_at = DateTime.utc_now()
assert {:ok, closed_ballot} = RankedVoting.close_ballot(ballot, closed_at)
assert %Ballot{closed_at: ^closed_at} = closed_ballot
end

test "failure: can not close an unpublished ballot" do
ballot = ballot_fixture()
assert {:error, :ballot_not_published} = RankedVoting.close_ballot(ballot)
end

test "failure: can not close a closed ballot" do
ballot = closed_ballot_fixture()
assert {:error, :ballot_already_closed} = RankedVoting.close_ballot(ballot)
end
end

describe "ballot_status/1" do
# TODO
end

describe "list_ballots/1" do
test "success: lists ballots start with zero ballots" do
assert [] = RankedVoting.list_ballots()
Expand Down
22 changes: 18 additions & 4 deletions test/support/fixtures/ballot_fixture.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ defmodule Support.Fixtures.BallotFixture do
Enum.into(attrs, %{
question_title: "What day should have dinner?",
possible_answers: "Monday, Tuesday, Wednesday, Thursday, Friday",
url_slug: "dinner-day-#{System.unique_integer()}",
published_at: nil
url_slug: "dinner-day-#{System.unique_integer()}"
})
end

Expand All @@ -26,7 +25,7 @@ defmodule Support.Fixtures.BallotFixture do

When not provided, all required attributes will be generated.
"""
@spec ballot_fixture(map()) :: {:ok, Ballot.t()}
@spec ballot_fixture(map()) :: Ballot.t()
def ballot_fixture(attrs \\ %{}) do
attrs = valid_ballot_attributes(attrs)
{:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
Expand All @@ -39,11 +38,26 @@ defmodule Support.Fixtures.BallotFixture do

When not provided, all required attributes will be generated.
"""
@spec published_ballot_fixture(map()) :: {:ok, Ballot.t()}
@spec published_ballot_fixture(map()) :: Ballot.t()
def published_ballot_fixture(attrs \\ %{}) do
attrs = valid_ballot_attributes(attrs)
{:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
{:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot)
published_ballot
end

@doc """
Creates a `Flick.RankedVoting.Ballot` entity in the `Flick.Repo` for the passed in
optional attributes, publishes the ballot, and then closes the ballot.

When not provided, all required attributes will be generated.
"""
@spec closed_ballot_fixture(map()) :: Ballot.t()
def closed_ballot_fixture(attrs \\ %{}) do
attrs = valid_ballot_attributes(attrs)
{:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
{:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot)
{:ok, closed_ballot} = Flick.RankedVoting.close_ballot(published_ballot)
closed_ballot
end
end
Loading