From 205778ae54354e42e56bc2ad0ce68c8d90f6f660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Fri, 22 Sep 2023 00:20:40 +0200 Subject: [PATCH] Add CSV export for all contacts in the project --- config/config.exs | 3 +- config/test.exs | 2 +- .../controllers/contact_controller.ex | 61 ++++++++++++++++--- lib/keila_web/router.ex | 1 + .../templates/contact/index.html.heex | 13 +++- .../controllers/contact_controller_test.exs | 31 ++++++++++ 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/config/config.exs b/config/config.exs index 62b2fe07..5d07eac4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,7 +8,8 @@ import Config config :keila, - ecto_repos: [Keila.Repo] + ecto_repos: [Keila.Repo], + csv_export_chunk_size: 100 # Configures the endpoint config :keila, KeilaWeb.Endpoint, diff --git a/config/test.exs b/config/test.exs index bca65e44..9b1a634d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,7 +14,7 @@ config :keila, Keila.Repo, timeout: 60_000, pool_size: 16 -config :keila, skip_migrations: true +config :keila, skip_migrations: true, csv_export_chunk_size: 3 # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/lib/keila_web/controllers/contact_controller.ex b/lib/keila_web/controllers/contact_controller.ex index 404501f0..32afd42c 100644 --- a/lib/keila_web/controllers/contact_controller.ex +++ b/lib/keila_web/controllers/contact_controller.ex @@ -4,16 +4,21 @@ defmodule KeilaWeb.ContactController do import Ecto.Changeset alias Keila.Contacts - plug :authorize - when action not in [ - :index, - :index_unsubscribed, - :index_unreachable, - :new, - :post_new, - :delete, - :import - ] + plug( + :authorize + when action not in [ + :index, + :index_unsubscribed, + :index_unreachable, + :new, + :post_new, + :delete, + :import, + :export + ] + ) + + @csv_export_chunk_size Application.compile_env!(:keila, :csv_export_chunk_size) @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() def index(conn, params) do @@ -152,6 +157,42 @@ defmodule KeilaWeb.ContactController do |> render("edit.html") end + @spec export(Plug.Conn.t(), map()) :: Plug.Conn.t() + def export(conn, %{"project_id" => project_id}) do + filename = "contacts_#{project_id}.csv" + + conn = + conn + |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") + |> put_resp_header("content-type", "text/csv") + |> send_chunked(200) + + header = + [["Email", "First name", "Last name", "Data"]] + |> NimbleCSV.RFC4180.dump_to_iodata() + |> IO.iodata_to_binary() + + {:ok,conn} = chunk(conn, header) + + Keila.Repo.transaction(fn -> + Contacts.stream_project_contacts(project_id, max_rows: @csv_export_chunk_size) + |> Stream.map(fn contact -> + data = if is_nil(contact.data), do: nil, else: Jason.encode!(contact.data) + [[contact.email, contact.first_name, contact.last_name, data]] + |> NimbleCSV.RFC4180.dump_to_iodata() + |> IO.iodata_to_binary() + end) + |> Stream.chunk_every(@csv_export_chunk_size) + |> Enum.reduce_while(conn, fn chunk, conn -> + case Plug.Conn.chunk(conn, chunk) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end + end) + end) + |> then(fn {:ok, conn} -> conn end) + end + defp current_project(conn), do: conn.assigns.current_project defp authorize(conn, _) do diff --git a/lib/keila_web/router.ex b/lib/keila_web/router.ex index 77f4e6e1..e7d2e0cf 100644 --- a/lib/keila_web/router.ex +++ b/lib/keila_web/router.ex @@ -97,6 +97,7 @@ defmodule KeilaWeb.Router do get "/projects/:project_id/contacts/new", ContactController, :new post "/projects/:project_id/contacts/new", ContactController, :post_new get "/projects/:project_id/contacts/import", ContactController, :import + get "/projects/:project_id/contacts/export", ContactController, :export get "/projects/:project_id/contacts/:id", ContactController, :edit put "/projects/:project_id/contacts/:id", ContactController, :post_edit delete "/projects/:project_id/contacts", ContactController, :delete diff --git a/lib/keila_web/templates/contact/index.html.heex b/lib/keila_web/templates/contact/index.html.heex index 3d88ce85..2bd0885d 100644 --- a/lib/keila_web/templates/contact/index.html.heex +++ b/lib/keila_web/templates/contact/index.html.heex @@ -1,4 +1,4 @@ -
+

<%= gettext("Contacts") %> @@ -16,6 +16,17 @@

+ + <%= if (@contacts_stats.active + @contacts_stats.unsubscribed + @contacts_stats.unreachable) == 0 do %> <%= render("_empty_state.html", assigns) %> <% else %> diff --git a/test/keila_web/controllers/contact_controller_test.exs b/test/keila_web/controllers/contact_controller_test.exs index 107a3355..06c3ee56 100644 --- a/test/keila_web/controllers/contact_controller_test.exs +++ b/test/keila_web/controllers/contact_controller_test.exs @@ -161,4 +161,35 @@ defmodule KeilaWeb.ContactControllerTest do assert Keila.Contacts.get_project_contacts(project.id) |> Enum.count() == 201 assert render(lv) =~ "You have successfully imported 201 contacts!" end + + @tag :contact_controller + test "GET /projects/:p_id/export CSV export single contact", %{conn: conn} do + {conn, project} = with_login_and_project(conn) + contact = insert!(:contact, project_id: project.id, data: %{"age" => 42}) + conn = get(conn, Routes.contact_path(conn, :export, project.id)) + rows = String.split(response(conn, 200), "\r\n") + + {_, disposition} = List.keyfind(conn.resp_headers, "content-disposition", 0) + assert disposition == "attachment; filename=\"contacts_#{project.id}.csv\"" + + assert rows == [ + "Email,First name,Last name,Data", + "#{contact.email},#{contact.first_name},#{contact.last_name},\"{\"\"age\"\":42}\"", + "" + ] + end + + @tag :contact_controller + test "GET /projects/:p_id/export CSV export contacts in multiple chunks", %{conn: conn} do + {conn, project} = with_login_and_project(conn) + insert!(:contact, project_id: project.id) + insert!(:contact, project_id: project.id) + insert!(:contact, project_id: project.id) + insert!(:contact, project_id: project.id) + conn = get(conn, Routes.contact_path(conn, :export, project.id)) + + assert conn.state == :chunked + rows = String.split(response(conn, 200), "\r\n") + assert length(rows) == 6 + end end