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

Feature/59 subjects csv export #60

Merged
merged 6 commits into from
Dec 22, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ defmodule ActiveMonitoring.Mixfile do
{:proper_case, "~> 1.0.2"},
{:xml_builder, "~> 0.1.1"},
{:ex_machina, "~> 2.0", only: :test},
{:oauth2, "~> 0.9"}
{:oauth2, "~> 0.9"},
{:csv, "~> 1.4.4"}
]
end

Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"},
"csv": {:hex, :csv, "1.4.4", "992f2e1418849a326fd1d9287801fa2d86091db4f9611f60781da6d236f64cd4", [], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.3.1", "157b3cedb2bfcb5359372a7766dd7a41091ad34578296e951f58a946fcab49c6", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.1.4", "d1ba932813ec0e0d9db481ef2c17777f1cefb11fc90fa7c142ff354972dfba7e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
Expand All @@ -21,6 +22,7 @@
"mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"oauth2": {:hex, :oauth2, "0.9.1", "cac86d87f35ec835bfe4c791263bdb88c0d8bf1617d64f555ede4e9d913e35ef", [:mix], [{:hackney, "~> 1.7", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"parallel_stream": {:hex, :parallel_stream, "1.0.5", "4c78d3e675f9eff885cbe252c89a8fc1d2fb803c0d03a914281e587834e09431", [], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.2.3", "b68dd6a7e6ff3eef38ad59771007d2f3f344988ea6e658e9b2c6ffb2ef494810", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.4 or ~> 1.3.3 or ~> 1.2.4 or ~> 1.1.8 or ~> 1.0.5", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "3.2.3", "450c749876ff1de4a78fdb305a142a76817c77a1cd79aeca29e5fc9a6c630b26", [:mix], [{:ecto, "~> 2.1", [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"},
"phoenix_html": {:hex, :phoenix_html, "2.9.3", "1b5a2122cbf743aa242f54dced8a4f1cc778b8bd304f4b4c0043a6250c58e258", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
"isomorphic-fetch": "^2.2.1",
"jquery": "^3.2.1",
"lodash": "^4.17.4",
"moment": "^2.20.1",
"phoenix": "file:deps/phoenix",
"phoenix_html": "file:deps/phoenix_html",
"prop-types": "^15.5.10",
"react": "^15.5.4",
"react-dom": "^15.5.4",
"react-dropzone": "^3.13.2",
"react-md": "^1.0.14",
"react-moment": "^0.6.8",
"react-redux": "^5.0.5",
"react-router": "^4.1.1",
"react-router-dom": "^4.1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule ActiveMonitoring.Repo.Migrations.AddIndicesForSubjectsStats do
use Ecto.Migration

def change do
create index(:calls, [:campaign_id, :subject_id], name: :calls_campaign_id_subject_id_index)
create index(:calls, [:campaign_id, :subject_id, :current_step], name: :calls_campaign_id_subject_id_current_step_index)
end
end
18 changes: 17 additions & 1 deletion test/controllers/subjects_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule ActiveMonitoring.SubjectsControllerTest do

setup %{conn: conn} do
user = build(:user, email: "[email protected]") |> Repo.insert!
campaign = build(:campaign, user: user) |> Repo.insert!
campaign = build(:campaign, user: user, monitor_duration: 30) |> Repo.insert!

other_user = build(:user, email: "[email protected]") |> Repo.insert!
other_campaign = build(:campaign, user: other_user) |> Repo.insert!
Expand Down Expand Up @@ -54,6 +54,16 @@ defmodule ActiveMonitoring.SubjectsControllerTest do
assert subj["phoneNumber"] == subject.phone_number
end

test "subjects csv export", %{conn: conn, campaign: campaign, subject: subject} do
conn = conn |> get(campaigns_subjects_export_csv_path(conn, :export_csv, campaign))
csv = conn |> response(200)
assert get_resp_header(conn, "content-disposition") == ["attachment; filename=\"export_#{campaign.name}_subjects.csv\""]
assert get_resp_header(conn, "content-type") == ["text/csv; charset=utf-8"]
[header, line1 | _] = csv |> String.split("\r\n")
assert header == "ID,Phone Number,Enroll date,First Call Date,Last Call Date,Last Successful Call,Active Case"
assert line1 == "#{subject.registration_identifier},#{subject.phone_number},#{subject.inserted_at},,,,true"
end

test "lists subjects by page size", %{conn: conn, campaign: campaign} do
response = conn |> get(campaigns_subjects_path(conn, :index, campaign, limit: 2)) |> json_response(200)
assert length(response["data"]["subjects"]) == 2
Expand Down Expand Up @@ -152,6 +162,12 @@ defmodule ActiveMonitoring.SubjectsControllerTest do
end
end

test "doesn't allow to export another user's campaign subjects", %{conn: conn, other_campaign: other_campaign} do
assert_error_sent 403, fn ->
conn |> get(campaigns_subjects_export_csv_path(conn, :export_csv, other_campaign))
end
end

test "not found when trying to list a campaign that doesn't exist subjects", %{conn: conn} do
assert_error_sent 404, fn ->
conn |> get(campaigns_subjects_path(conn, :index, -1))
Expand Down
33 changes: 33 additions & 0 deletions web/controllers/subjects_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule ActiveMonitoring.SubjectsController do
subjects = Repo.get!(Campaign, campaign_id)
|> authorize_campaign(conn)
|> assoc(:subjects)
|> preload(:campaign)
|> limit(^limit)
|> offset(^offset)
|> Repo.all
Expand All @@ -26,6 +27,36 @@ defmodule ActiveMonitoring.SubjectsController do
render(conn, "index.json", subjects: subjects, count: count)
end

def export_csv(conn, %{"campaigns_id" => campaign_id}) do
header = ["ID", "Phone Number", "Enroll date", "First Call Date", "Last Call Date", "Last Successful Call", "Active Case"]

campaign = Repo.get!(Campaign, campaign_id)
|> authorize_campaign(conn)

subjects = campaign
|> assoc(:subjects)
|> preload(:campaign)
|> Repo.all

csv_rows = subjects
|> Stream.map(fn subject ->
[
subject.registration_identifier,
subject.phone_number,
subject |> Subject.enroll_date,
subject |> Subject.first_call_date || "",
subject |> Subject.last_call_date || "",
subject |> Subject.last_successful_call_date || "",
subject |> Subject.active_case
]
end)

rows = Stream.concat([[header], csv_rows])

filename = "export_#{campaign.name}_subjects.csv"
conn |> csv_stream(rows, filename)
end

defp page_offset page, limit do
newPage = case Integer.parse(page) do
:error -> 1
Expand All @@ -52,6 +83,7 @@ defmodule ActiveMonitoring.SubjectsController do

case Repo.insert(changeset) do
{:ok, subject} ->
subject = Repo.preload(subject, :campaign)
conn
|> put_status(:created)
|> put_resp_header("location", campaigns_subjects_path(conn, :index, campaign))
Expand All @@ -73,6 +105,7 @@ defmodule ActiveMonitoring.SubjectsController do

case Repo.update(changeset) do
{:ok, subject} ->
subject = Repo.preload(subject, :campaign)
render(conn, "show.json", subject: subject)

{:error, changeset} ->
Expand Down
19 changes: 19 additions & 0 deletions web/helpers/csv_helper.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule CSV.Helper do
import Plug.Conn
@chunk_lines 100

def csv_stream(conn, rows, filename) do
conn = conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"")
|> send_chunked(200)

rows
|> CSV.encode
|> Stream.chunk(@chunk_lines, @chunk_lines, [])
|> Enum.reduce(conn, fn (lines, conn) ->
{:ok, conn} = chunk(conn, lines)
conn
end)
end
end
51 changes: 51 additions & 0 deletions web/models/subject.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,57 @@ defmodule ActiveMonitoring.Subject do
|> assoc_constraint(:campaign)
end

def enroll_date(%Subject{inserted_at: inserted_at}) do
inserted_at
end

def first_call_date(%Subject{id: subject_id, campaign_id: campaign_id}) do
call = Repo.one(from c in Call,
Copy link
Member

Choose a reason for hiding this comment

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

@mverzilli reworking these queries is a big issue. We're going to merge this as-is, and have filled #61 to not forget about it.

Copy link
Member

Choose a reason for hiding this comment

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

We've added indices to help the queries we're currently executing, BTW.

where: c.subject_id == ^subject_id and c.campaign_id == ^campaign_id,
order_by: [asc: c.inserted_at],
limit: 1)

case call do
nil -> nil
%Call{inserted_at: inserted_at} -> inserted_at
end
end

def last_call_date(%Subject{id: subject_id, campaign_id: campaign_id}) do
call = Repo.one(from c in Call,
where: c.subject_id == ^subject_id and c.campaign_id == ^campaign_id,
order_by: [desc: c.inserted_at],
limit: 1)

case call do
nil -> nil
%Call{inserted_at: inserted_at} -> inserted_at
end
end

def last_successful_call_date(%Subject{id: subject_id, campaign_id: campaign_id}) do
call = Repo.one(from c in Call,
where: c.subject_id == ^subject_id and c.campaign_id == ^campaign_id and c.current_step == "thanks",
order_by: [desc: c.updated_at],
limit: 1)

case call do
nil -> nil
%Call{updated_at: updated_at} -> updated_at
end
end

def active_case(%Subject{id: subject_id, campaign: campaign} = subject, now) do
subject_enroll_date = Subject.enroll_date(subject)

final_enroll_date = subject_enroll_date |> Timex.shift(days: campaign.monitor_duration)
Timex.compare(now, subject_enroll_date) > 0 && Timex.compare(final_enroll_date, now) > 0
end

def active_case(subject) do
active_case(subject, Timex.now())
end

def stats(campaign_id) do
campaign = Repo.get(Campaign, campaign_id)
cases =
Expand Down
1 change: 1 addition & 0 deletions web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ defmodule ActiveMonitoring.Router do
resources "/campaigns", CampaignsController, only: [:index, :create, :show, :update, :delete] do
put "/launch", CampaignsController, :launch, as: :launch

get "/subjects/export", SubjectsController, :export_csv, as: :subjects_export_csv
resources "/subjects", SubjectsController, only: [:index, :create, :update, :delete]
end
resources "/channels", ChannelsController, only: [:index]
Expand Down
44 changes: 43 additions & 1 deletion web/static/js/components/subjects/Subjects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import TableRow from 'react-md/lib/DataTables/TableRow'
import TableColumn from 'react-md/lib/DataTables/TableColumn'
import Dialog from 'react-md/lib/Dialogs'
import CircularProgress from 'react-md/lib/Progress/CircularProgress'
import Button from 'react-md/lib/Buttons/Button'
import Moment from 'react-moment'

import * as collectionActions from '../../actions/subjects'
import * as itemActions from '../../actions/subject'
Expand All @@ -26,6 +28,7 @@ class SubjectsList extends Component {
showSubjectForm: () => void,
onPageChange: (page: number) => void,
onSubjectClick: (subject: Subject) => void,
exportCsv: () => void,
currentPage: ?number,
rowsPerPage: number,
count: number,
Expand All @@ -45,13 +48,19 @@ class SubjectsList extends Component {

return (
<div className='md-grid'>
<Button flat primary label='Export CSV' onClick={this.props.exportCsv}>file_download</Button>
<div className='md-cell md-cell--12'>
<Card tableCard>
<DataTable plain className='app-listing' baseId='subjects'>
<TableHeader>
<TableRow>
<TableColumn>ID</TableColumn>
<TableColumn>Phone Number</TableColumn>
<TableColumn>Enroll Date</TableColumn>
<TableColumn>First Call</TableColumn>
<TableColumn>Last Call</TableColumn>
<TableColumn>Last Successful Call</TableColumn>
<TableColumn>Active?</TableColumn>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -78,6 +87,22 @@ class SubjectsList extends Component {
}
}

class FormatDate extends Component {
props: {
date: ?Date,
}

render() {
const date = this.props.date

if (date !== null) {
return (<Moment format='MMM DD, YYYY HH:mm' date={date} />)
} else {
return (<span>-</span>)
}
}
}

class SubjectItem extends Component {
props: {
subject: Subject,
Expand All @@ -90,6 +115,19 @@ class SubjectItem extends Component {
<TableRow onClick={() => this.props.onClick(subject)}>
<TableColumn>{subject.registrationIdentifier}</TableColumn>
<TableColumn>{subject.phoneNumber}</TableColumn>
<TableColumn>
<FormatDate date={subject.enrollDate} />
</TableColumn>
<TableColumn>
<FormatDate date={subject.firstCallDate} />
</TableColumn>
<TableColumn>
<FormatDate date={subject.lastCallDate} />
</TableColumn>
<TableColumn>
<FormatDate date={subject.lastSuccessfulCallDate} />
</TableColumn>
<TableColumn>{subject.activeCase ? 'Yes' : 'No'}</TableColumn>
</TableRow>
)
}
Expand Down Expand Up @@ -144,6 +182,10 @@ class Subjects extends Component {
this.props.collectionActions.fetchSubjects(this.props.campaignId, limit, targetPage)
}

exportCsv() {
window.location.href = `/api/v1/campaigns/${this.props.campaignId}/subjects/export`
}

createSubject() {
if (this.props.subjects.editingSubject != null) {
this.props.itemActions.createSubject(this.props.campaignId, this.props.subjects.editingSubject)
Expand Down Expand Up @@ -197,6 +239,7 @@ class Subjects extends Component {
currentPage={page}
rowsPerPage={limit}
showSubjectForm={() => this.showSubjectForm()}
exportCsv={() => this.exportCsv()}
onSubjectClick={(subject) => this.editSubject(subject)}
onPageChange={(targetPage) => this.goToPage(targetPage)} />)
}
Expand Down Expand Up @@ -227,7 +270,6 @@ class Subjects extends Component {
if (editingSubject != null) {
subjectForm = this.subjectForm(editingSubject)
}

let tableOrLoadingIndicator = fetching ? this.circularProgress() : this.subjectsList()

return (
Expand Down
5 changes: 5 additions & 0 deletions web/static/js/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type Subject = {
id: number,
phoneNumber: string,
registrationIdentifier: string,
enrollDate: Date,
firstCallDate: ?Date,
lastCallDate: ?Date,
lastSuccessfulCallDate: ?Date,
activeCase: boolean,
}

export type SubjectParams = {
Expand Down
6 changes: 6 additions & 0 deletions web/views/subjects_view.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule ActiveMonitoring.SubjectsView do
use ActiveMonitoring.Web, :view
alias ActiveMonitoring.{Subject}

def render("index.json", %{subjects: subjects, count: count}) do
rendered = subjects |> Enum.map(fn(subject) ->
Expand All @@ -21,6 +22,11 @@ defmodule ActiveMonitoring.SubjectsView do
campaign_id: subject.campaign_id,
registration_identifier: subject.registration_identifier,
phone_number: subject.phone_number,
enroll_date: Subject.enroll_date(subject),
first_call_date: Subject.first_call_date(subject),
last_call_date: Subject.last_call_date(subject),
last_successful_call_date: Subject.last_successful_call_date(subject),
active_case: Subject.active_case(subject),
}
end
end
1 change: 1 addition & 0 deletions web/web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ defmodule ActiveMonitoring.Web do
import ActiveMonitoring.Gettext

import User.Helper
import CSV.Helper
end
end

Expand Down
Loading