diff --git a/lib/voting/voter/import_voters.ex b/lib/voting/voter/import_voters.ex new file mode 100644 index 0000000..85f1a7c --- /dev/null +++ b/lib/voting/voter/import_voters.ex @@ -0,0 +1,90 @@ +defmodule Voting.ImportVoters do + @moduledoc """ + Importing voters from a CSV file + """ + + alias Voting.{ElectionRepo, Repo, Voter} + + import Ecto.Changeset + + def run(file, election_id) do + election = ElectionRepo.get_election!(election_id) + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + rows = + file + |> File.stream!() + |> CSV.decode(headers: true) + |> Stream.map(fn row -> + case row do + {:ok, voter} -> {:ok, build_entry(voter, election.id, now)} + {:error, error} -> {:error, error} + end + end) + |> Enum.to_list() + + entries = + rows + |> Keyword.get_values(:ok) + |> validate_entries() + |> find_voters() + |> insert_entries() + + %{ + inserted: entries.inserted, + invalid: entries.invalid, + already_exist: entries.already_exist, + errors: Keyword.get_values(rows, :error) + } + end + + defp build_entry(voter, election_id, now) do + %{ + name: Map.get(voter, "name"), + registration_number: Map.get(voter, "registration_number"), + role: Map.get(voter, "role"), + admission_date: parse_date(Map.get(voter, "admission_date")), + inserted_at: now, + updated_at: now, + election_id: election_id + } + end + + defp parse_date(value) do + case Timex.parse(value, "{D}/{0M}/{YYYY}") do + {:ok, date} -> Timex.to_date(date) + {:error, _} -> "" + end + end + + defp validate_entries(entries) do + {valid, invalid} = + Enum.split_with(entries, fn entry -> + %Voter{} + |> cast(entry, [:name, :registration_number, :role, :admission_date]) + |> validate_required([:name, :registration_number, :role, :admission_date]) + |> Map.get(:valid?) + end) + + %{valid: valid, invalid: invalid} + end + + defp find_voters(%{valid: entries, invalid: invalid}) do + {insertable, already_exist} = + Enum.split_with(entries, fn entry -> + Voter + |> Repo.get_by(%{ + election_id: entry.election_id, + registration_number: entry.registration_number + }) + |> is_nil() + end) + + %{valid: insertable, invalid: invalid, already_exist: already_exist} + end + + defp insert_entries(%{valid: entries, invalid: invalid, already_exist: already_exist}) do + Repo.insert_all(Voter, entries) + %{inserted: entries, invalid: invalid, already_exist: already_exist} + end +end diff --git a/lib/voting/voter/voter.ex b/lib/voting/voter/voter.ex new file mode 100644 index 0000000..155b444 --- /dev/null +++ b/lib/voting/voter/voter.ex @@ -0,0 +1,20 @@ +defmodule Voting.Voter do + @moduledoc """ + Voter schema + """ + + use Ecto.Schema + + alias Voting.Election + + schema "voters" do + field :admission_date, :date + field :name, :string + field :registration_number, :string + field :role, :string + field :voted, :boolean, default: false + belongs_to :election, Election + + timestamps() + end +end diff --git a/mix.exs b/mix.exs index a9b17a9..1e05caf 100644 --- a/mix.exs +++ b/mix.exs @@ -20,7 +20,7 @@ defmodule Voting.MixProject do def application do [ mod: {Voting.Application, []}, - extra_applications: [:logger, :runtime_tools] + extra_applications: [:logger, :runtime_tools, :timex] ] end @@ -50,7 +50,9 @@ defmodule Voting.MixProject do {:hackney, "~> 1.9"}, {:sweet_xml, "~> 0.6"}, {:uuid, "~> 1.1"}, - {:mimic, "~> 1.2", only: :test} + {:mimic, "~> 1.2", only: :test}, + {:csv, "~> 2.3"}, + {:timex, "~> 3.5"} ] end diff --git a/mix.lock b/mix.lock index 6f5a425..60d669c 100644 --- a/mix.lock +++ b/mix.lock @@ -2,11 +2,13 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.3.2", "08d456dcf3c24da162d02953fb07267e444469d8dad3a2ae47794938ea467b3a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b11d28cce1f1f399dddffd42d8e21dcad783309e230f84b70267b1a5546468b6"}, + "csv": {:hex, :csv, "2.3.1", "9ce11eff5a74a07baf3787b2b19dd798724d29a9c3a492a41df39f6af686da0e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "86626e1c89a4ad9a96d0d9c638f9e88c2346b89b4ba1611988594ebe72b5d5ee"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, @@ -25,6 +27,7 @@ "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mimic": {:hex, :mimic, "1.2.0", "08f783232033bc69c1c1264e4a1cceb8de6f07caea9eab3440290437743a7593", [:mix], [], "hexpm", "fed5a64c49e544e60caa9b60af1f97eb40447ed9bd5857f992c2fffd3ac9a6de"}, + "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "phoenix": {:hex, :phoenix, "1.4.16", "2cbbe0c81e6601567c44cc380c33aa42a1372ac1426e3de3d93ac448a7ec4308", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "856cc1a032fa53822737413cf51aa60e750525d7ece7d1c0576d90d7c0f05c24"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, @@ -37,6 +40,8 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, + "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, } diff --git a/priv/repo/migrations/20200404175521_create_voters.exs b/priv/repo/migrations/20200404175521_create_voters.exs new file mode 100644 index 0000000..ae85414 --- /dev/null +++ b/priv/repo/migrations/20200404175521_create_voters.exs @@ -0,0 +1,19 @@ +defmodule Voting.Repo.Migrations.CreateVoters do + use Ecto.Migration + + def change do + create table(:voters) do + add :name, :string, null: false + add :admission_date, :date, null: false + add :registration_number, :string, null: false + add :role, :string, null: false + add :voted, :boolean, default: false, null: false + add :election_id, references(:elections, on_delete: :nothing) + + timestamps() + end + + create index(:voters, [:election_id]) + create unique_index(:voters, [:election_id, :registration_number]) + end +end diff --git a/test/csv/voters.csv b/test/csv/voters.csv new file mode 100644 index 0000000..0491833 --- /dev/null +++ b/test/csv/voters.csv @@ -0,0 +1,6 @@ +name,admission_date,registration_number,role +Thiago Guimarães,10/01/2019,1,Programador +Maria dos Santos,10/01/2019,2,Secretária +José dos Santos;10/01/2019;3;Segurança +João dos Santos,10/01/2019,4,Porteiro +Roberto dos Santos,,5,Contador diff --git a/test/voting/voter/import_voters_test.exs b/test/voting/voter/import_voters_test.exs new file mode 100644 index 0000000..2bdf84b --- /dev/null +++ b/test/voting/voter/import_voters_test.exs @@ -0,0 +1,56 @@ +defmodule Voting.ImportVotersTest do + use Voting.DataCase, async: true + + import Voting.Factory + + alias Voting.ImportVoters + + describe "run/2" do + test "returns the inserted, invalid, already_exist and errors" do + %{id: election_id} = election = insert(:election) + csv = Path.expand("../../csv/voters.csv", __DIR__) + + assert %{ + inserted: inserted, + invalid: invalid, + already_exist: already_exist, + errors: errors + } = ImportVoters.run(csv, election.id) + + assert [ + %{registration_number: "1", election_id: ^election_id}, + %{registration_number: "2", election_id: ^election_id}, + %{registration_number: "4", election_id: ^election_id} + ] = inserted + + assert [ + %{registration_number: "5", election_id: ^election_id} + ] = invalid + + assert [] = already_exist + + assert ["Row has length 1 - expected length 4 on line 4"] = errors + + assert %{ + inserted: inserted, + invalid: invalid, + already_exist: already_exist, + errors: errors + } = ImportVoters.run(csv, election.id) + + assert [] = inserted + + assert [ + %{registration_number: "1", election_id: ^election_id}, + %{registration_number: "2", election_id: ^election_id}, + %{registration_number: "4", election_id: ^election_id} + ] = already_exist + + assert [ + %{registration_number: "5", election_id: ^election_id} + ] = invalid + + assert ["Row has length 1 - expected length 4 on line 4"] = errors + end + end +end