Skip to content

Commit

Permalink
Merge pull request #238 from katafrakt/csv-export
Browse files Browse the repository at this point in the history
Add contacts CSV export capability
  • Loading branch information
wmnnd authored Oct 1, 2023
2 parents fd406f6 + 6a23b47 commit 05a8bd1
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 23 deletions.
5 changes: 3 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
# General application configuration
import Config

config :keila,
ecto_repos: [Keila.Repo]
config :keila, ecto_repos: [Keila.Repo]

config :keila, KeilaWeb.ContactsCsvExport, chunk_size: 100

# Configures the endpoint
config :keila, KeilaWeb.Endpoint,
Expand Down
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ config :keila, Keila.Repo,

config :keila, skip_migrations: true

config :keila, KeilaWeb.ContactsCsvExport, chunk_size: 3

# We don't run a server during test. If one is required,
# you can enable the server option below.
config :keila, KeilaWeb.Endpoint,
Expand Down
29 changes: 19 additions & 10 deletions lib/keila_web/controllers/contact_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ 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
]
)

@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, params) do
Expand Down Expand Up @@ -152,6 +155,12 @@ 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"
KeilaWeb.ContactsCsvExport.stream_csv_response(conn, filename, project_id)
end

defp current_project(conn), do: conn.assigns.current_project

defp authorize(conn, _) do
Expand Down
10 changes: 9 additions & 1 deletion lib/keila_web/controllers/segment_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule KeilaWeb.SegmentController do
import Ecto.Changeset
import Phoenix.LiveView.Controller

plug :authorize when action not in [:index, :new, :create, :delete]
plug(:authorize when action not in [:index, :new, :create, :delete])

@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, _params) do
Expand Down Expand Up @@ -71,6 +71,14 @@ defmodule KeilaWeb.SegmentController do
|> render("delete.html")
end

@spec contacts_export(Plug.Conn.t(), map()) :: Plug.Conn.t()
def contacts_export(conn, %{"project_id" => project_id, "id" => segment_id}) do
filename = "contacts_#{project_id}_segment_#{segment_id}.csv"

filter = conn.assigns.segment.filter || %{}
KeilaWeb.ContactsCsvExport.stream_csv_response(conn, filename, project_id, filter: filter)
end

defp current_project(conn), do: conn.assigns.current_project

defp authorize(conn, _) do
Expand Down
45 changes: 45 additions & 0 deletions lib/keila_web/helpers/contacts_csv_export.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule KeilaWeb.ContactsCsvExport do
@moduledoc """
Shared logic of exporting list of contacts to CSV file with support of streaming.
"""
alias Keila.Contacts
import Plug.Conn

@chunk_size Application.compile_env!(:keila, KeilaWeb.ContactsCsvExport)[:chunk_size]

def stream_csv_response(conn, filename, project_id, stream_opts \\ []) do
stream_opts = Keyword.merge(stream_opts, max_rown: @chunk_size)

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", "Status"]]
|> NimbleCSV.RFC4180.dump_to_iodata()
|> IO.iodata_to_binary()

{:ok, conn} = chunk(conn, header)

Keila.Repo.transaction(fn ->
Contacts.stream_project_contacts(project_id, stream_opts)
|> 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, contact.status]]
|> NimbleCSV.RFC4180.dump_to_iodata()
|> IO.iodata_to_binary()
end)
|> Stream.chunk_every(@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
end
2 changes: 2 additions & 0 deletions lib/keila_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -120,6 +121,7 @@ defmodule KeilaWeb.Router do
get "/projects/:project_id/segments/new", SegmentController, :new
post "/projects/:project_id/segments", SegmentController, :create
get "/projects/:project_id/segments/:id", SegmentController, :edit
get "/projects/:project_id/segments/:id/contacts_export", SegmentController, :contacts_export
delete "/projects/:project_id/segments", SegmentController, :delete

get "/projects/:project_id/campaigns", CampaignController, :index
Expand Down
16 changes: 11 additions & 5 deletions lib/keila_web/templates/contact/index.html.heex
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
<div class="container flex py-8 sm:py-11 mb-4">
<div class="container flex pt-8 sm:pt-11 mb-4">
<div class="flex-grow gap-4 flex flex-col sm:flex-row sm:items-center">
<h1 class="text-2xl sm:text-3xl text-gray-100">
<%= gettext("Contacts") %>
</h1>
<div class="flex-grow flex flex-row-reverse justify-end gap-4 sm:flex-row">
<a href={Routes.contact_path(@conn, :new, @current_project.id)} class="button">
<%= render_icon(:document_add) %>
<%= gettext("Create") %>
</a>
<%= if @contacts.count > 0 do %>
<a href={Routes.contact_path(@conn, :export, @current_project.id)} class="button">
<%= render_icon(:download) %>
<%= gettext("Download") %>
</a>
<% end %>
<a href={Routes.contact_path(@conn, :import, @current_project.id)} class="button">
<%= render_icon(:user_add) %>
<%= gettext("Import") %>
</a>
<a href={Routes.contact_path(@conn, :new, @current_project.id)} class="button">
<%= render_icon(:document_add) %>
<%= gettext("Create") %>
</a>
</div>
</div>
</div>
Expand Down
24 changes: 19 additions & 5 deletions lib/keila_web/templates/segment/edit_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,32 @@
<div class="form-row">
<%= if @valid_filter do %>
<div>
<h2 class="">
<%= gettext("Contacts in this segment (%{count})", count: @contacts.count) %>
</h2>
<div class="sm:flex gap-4 items-center mb-4 mt-4">
<h2 class="font-light text-2xl">
<%= gettext("Contacts in this segment (%{count})", count: @contacts.count) %>
</h2>

<a
href={
Routes.segment_path(@socket, :contacts_export, @current_project.id, @segment.id)
}
class="button button--small"
target="_blank"
>
<%= render_icon(:download) %>
<%= gettext("Download") %>
</a>
</div>
<table class="w-full text-sm">
<tr class="text-left">
<th class="p-2">Email</th>
<th class="p-2 pl-0">Email</th>
<th class="p-2 hidden lg:table-cell">First name</th>
<th class="p-2 hidden lg:table-cell">Last name</th>
<th class="p-2 hidden lg:table-cell">Added</th>
</tr>
<%= for contact <- @contacts.data do %>
<tr>
<td class="p-2">
<td class="p-2 pl-0">
<a
class="button button--text overflow-x-hidden"
href={Routes.contact_path(@socket, :edit, @current_project.id, contact.id)}
Expand All @@ -194,6 +207,7 @@
<%= pagination_nav(@contacts, phx_click: "change-page") %>
</div>
</div>
<div></div>
<% end %>
</div>
</.form>
Expand Down
32 changes: 32 additions & 0 deletions test/keila_web/controllers/contact_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,36 @@ 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,Status",
"#{contact.email},#{contact.first_name},#{contact.last_name},\"{\"\"age\"\":42}\",active",
""
]
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, status: :unreachable)
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
assert Enum.at(rows, 2) =~ ~r/,unreachable/
end
end
38 changes: 38 additions & 0 deletions test/keila_web/controllers/segment_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,42 @@ defmodule KeilaWeb.SegmentControllerTest do
assert segment == Contacts.get_segment(segment.id)
end
end

@tag :segment_controller
test "GET /projects/:p_id/export CSV export contacts in multiple chunks", %{conn: conn} do
{conn, project} = with_login_and_project(conn)

segment =
insert!(:contacts_segment,
project_id: project.id,
filter: %{"email" => %{"$like" => "%keila.io"}}
)

insert!(:contact, project_id: project.id, email: "[email protected]")

contact =
insert!(:contact, project_id: project.id, status: :unreachable, email: "[email protected]")

insert!(:contact, project_id: project.id, email: "[email protected]")
insert!(:contact, project_id: project.id, email: "[email protected]")
insert!(:contact, project_id: project.id, email: "[email protected]")
conn = get(conn, Routes.segment_path(conn, :contacts_export, project.id, segment.id))

assert conn.state == :chunked
rows = String.split(response(conn, 200), "\r\n")
assert length(rows) == 6
assert Enum.at(rows, 1) =~ ~r/,unreachable/

{_, disposition} = List.keyfind(conn.resp_headers, "content-disposition", 0)

assert disposition ==
"attachment; filename=\"contacts_#{project.id}_segment_#{segment.id}.csv\""

assert [
"Email,First name,Last name,Data,Status",
contact_row | _
] = rows

assert contact_row == "[email protected],#{contact.first_name},#{contact.last_name},,unreachable"
end
end

0 comments on commit 05a8bd1

Please sign in to comment.