diff options
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Dashboard.pm | 270 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Reports.pm | 54 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/BathNES.pm | 49 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Buckinghamshire.pm | 7 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Oxfordshire.pm | 7 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/TfL.pm | 43 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Zurich.pm | 14 | ||||
-rw-r--r-- | perllib/FixMyStreet/Reporting.pm | 337 |
8 files changed, 421 insertions, 360 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index 6fd4154b9..0d0a704bb 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -6,9 +6,9 @@ use DateTime; use Encode; use JSON::MaybeXS; use Path::Tiny; -use Text::CSV; use Time::Piece; use FixMyStreet::DateRange; +use FixMyStreet::Reporting; BEGIN { extends 'Catalyst::Controller'; } @@ -135,14 +135,11 @@ sub index : Path : Args(0) { $c->stash->{end_date} = $c->get_param('end_date'); $c->stash->{q_state} = $c->get_param('state') || ''; - $c->forward('construct_rs_filter', [ $c->get_param('updates') ]); + my $reporting = $c->forward('construct_rs_filter', [ $c->get_param('updates') ]); if ( $c->get_param('export') ) { - if ($c->get_param('updates')) { - $c->forward('export_as_csv_updates'); - } else { - $c->forward('export_as_csv'); - } + $reporting->csv_parameters; + $reporting->generate_csv_http($c); } else { $c->forward('generate_grouped_data'); $self->generate_summary_figures($c); @@ -152,37 +149,19 @@ sub index : Path : Args(0) { sub construct_rs_filter : Private { my ($self, $c, $updates) = @_; - my $table_name = $updates ? 'problem' : 'me'; - - my %where; - $where{areas} = [ map { { 'like', "%,$_,%" } } @{$c->stash->{ward}} ] - if @{$c->stash->{ward}}; - $where{"$table_name.category"} = $c->stash->{category} - if $c->stash->{category}; - - my $state = $c->stash->{q_state}; - if ( FixMyStreet::DB::Result::Problem->fixed_states->{$state} ) { # Probably fixed - council - $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; - } elsif ( $state ) { - $where{"$table_name.state"} = $state; - } else { - $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->visible_states() ]; - } - - my $days30 = DateTime->now(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(days => 30); - $days30->truncate( to => 'day' ); - - my $range = FixMyStreet::DateRange->new( + my $reporting = FixMyStreet::Reporting->new( + type => $updates ? 'updates' : 'problems', + category => $c->stash->{category}, + state => $c->stash->{q_state}, + wards => $c->stash->{ward}, + body => $c->stash->{body} || undef, start_date => $c->stash->{start_date}, - start_default => $days30, end_date => $c->stash->{end_date}, - formatter => $c->model('DB')->storage->datetime_parser, + user => $c->user_exists ? $c->user->obj : undef, ); - $where{"$table_name.confirmed"} = $range->sql; - $c->stash->{params} = \%where; - my $rs = $updates ? $c->cobrand->updates : $c->cobrand->problems; - $c->stash->{objects_rs} = $rs->to_body($c->stash->{body})->search( \%where ); + $c->stash($reporting->construct_rs_filter); + return $reporting; } sub generate_grouped_data : Private { @@ -304,229 +283,6 @@ sub generate_body_response_time : Private { $c->stash->{body_average} = $avg ? int($avg / 60 / 60 / 24 + 0.5) : 0; } -sub csv_filename { - my ($self, $c, $updates) = @_; - my %where = ( - category => $c->stash->{category}, - state => $c->stash->{q_state}, - ward => join(',', @{$c->stash->{ward}}), - ); - $where{body} = $c->stash->{body}->id if $c->stash->{body}; - join '-', - $c->req->uri->host, - $updates ? ('updates') : (), - map { - my $value = $where{$_}; - (defined $value and length $value) ? ($_, $value) : () - } sort keys %where -}; - -sub export_as_csv_updates : Private { - my ($self, $c) = @_; - - my $csv = $c->stash->{csv} = { - objects => $c->stash->{objects_rs}->search_rs({}, { - order_by => ['me.confirmed', 'me.id'], - '+columns' => ['problem.bodies_str'], - cursor_page_size => 1000, - }), - headers => [ - 'Report ID', 'Update ID', 'Date', 'Status', 'Problem state', - 'Text', 'User Name', 'Reported As', - ], - columns => [ - 'problem_id', 'id', 'confirmed', 'state', 'problem_state', - 'text', 'user_name_display', 'reported_as', - ], - filename => $self->csv_filename($c, 1), - user => $c->user_exists ? $c->user->obj : undef, - }; - $c->cobrand->call_hook(dashboard_export_updates_add_columns => $csv); - $c->forward('generate_csv'); -} - -sub export_as_csv : Private { - my ($self, $c) = @_; - - my $groups = $c->cobrand->enable_category_groups ? 1 : 0; - my $join = ['comments']; - my $columns = ['comments.id', 'comments.problem_state', 'comments.state', 'comments.confirmed', 'comments.mark_fixed']; - if ($groups) { - push @$join, 'contact'; - push @$columns, 'contact.id', 'contact.extra'; - } - my $csv = $c->stash->{csv} = { - objects => $c->stash->{objects_rs}->search_rs({}, { - join => $join, - collapse => 1, - '+columns' => $columns, - order_by => ['me.confirmed', 'me.id'], - cursor_page_size => 1000, - }), - headers => [ - 'Report ID', - 'Title', - 'Detail', - 'User Name', - 'Category', - $groups ? ('Subcategory') : (), - 'Created', - 'Confirmed', - 'Acknowledged', - 'Fixed', - 'Closed', - 'Status', - 'Latitude', 'Longitude', - 'Query', - 'Ward', - 'Easting', - 'Northing', - 'Report URL', - 'Site Used', - 'Reported As', - ], - columns => [ - 'id', - 'title', - 'detail', - 'user_name_display', - 'category', - $groups ? ('subcategory') : (), - 'created', - 'confirmed', - 'acknowledged', - 'fixed', - 'closed', - 'state', - 'latitude', 'longitude', - 'postcode', - 'wards', - 'local_coords_x', - 'local_coords_y', - 'url', - 'site_used', - 'reported_as', - ], - filename => $self->csv_filename($c, 0), - user => $c->user_exists ? $c->user->obj : undef, - category => $c->stash->{category}, - contacts => $c->stash->{contacts}, - }; - $c->cobrand->call_hook(dashboard_export_problems_add_columns => $csv); - $c->forward('generate_csv'); -} - -=head2 generate_csv - -Generates a CSV output, given a 'csv' stash hashref containing: -* filename: filename to be used in output -* problems: a resultset of the rows to output -* headers: an arrayref of the header row strings -* columns: an arrayref of the columns (looked up in the row's as_hashref, plus -the following: user_name_display, acknowledged, fixed, closed, wards, -local_coords_x, local_coords_y, url). -* extra_data: If present, a function that is passed the report and returns a -hashref of extra data to include that can be used by 'columns'. - -=cut - -sub generate_csv : Private { - my ($self, $c) = @_; - - my $filename = $c->stash->{csv}->{filename}; - $c->res->content_type('text/csv; charset=utf-8'); - $c->res->header('content-disposition' => "attachment; filename=\"${filename}.csv\""); - - # Emit a header (copying Drupal's naming) telling an intermediary (e.g. - # Varnish) not to buffer the output. Varnish will need to know this, e.g.: - # if (beresp.http.Surrogate-Control ~ "BigPipe/1.0") { - # set beresp.do_stream = true; - # set beresp.ttl = 0s; - # } - $c->res->header('Surrogate-Control' => 'content="BigPipe/1.0"'); - - # Tell nginx not to buffer this response - $c->res->header('X-Accel-Buffering' => 'no'); - - # Define an empty body so the web view doesn't get added at the end - $c->res->body(""); - - # Old parameter renaming - $c->stash->{csv}->{objects} //= $c->stash->{csv}->{problems}; - - my $csv = Text::CSV->new({ binary => 1, eol => "\n" }); - $csv->print($c->response, $c->stash->{csv}->{headers}); - - my $fixed_states = FixMyStreet::DB::Result::Problem->fixed_states; - my $closed_states = FixMyStreet::DB::Result::Problem->closed_states; - - my %asked_for = map { $_ => 1 } @{$c->stash->{csv}->{columns}}; - - my $objects = $c->stash->{csv}->{objects}; - while ( my $obj = $objects->next ) { - my $hashref = $obj->as_hashref(\%asked_for); - - $hashref->{user_name_display} = $obj->anonymous - ? '(anonymous)' : $obj->name; - - if ($asked_for{acknowledged}) { - for my $comment ($obj->comments) { - my $problem_state = $comment->problem_state or next; - next unless $comment->state eq 'confirmed'; - next if $problem_state eq 'confirmed'; - $hashref->{acknowledged} //= $comment->confirmed; - $hashref->{fixed} //= $fixed_states->{ $problem_state } || $comment->mark_fixed ? - $comment->confirmed : undef; - if ($closed_states->{ $problem_state }) { - $hashref->{closed} = $comment->confirmed; - last; - } - } - } - - if ($asked_for{wards}) { - $hashref->{wards} = join ', ', - map { $c->stash->{children}->{$_}->{name} } - grep {$c->stash->{children}->{$_} } - split ',', $hashref->{areas}; - } - - if ($obj->can('local_coords') && $asked_for{local_coords_x}) { - ($hashref->{local_coords_x}, $hashref->{local_coords_y}) = - $obj->local_coords; - } - - if ($asked_for{subcategory}) { - my $group = $obj->contact ? $obj->contact->groups : []; - $group = join(',', @$group); - if ($group) { - $hashref->{subcategory} = $obj->category; - $hashref->{category} = $group; - } - } - - if ($obj->can('url')) { - my $base = $c->cobrand->base_url_for_report($obj->can('problem') ? $obj->problem : $obj); - $hashref->{url} = join '', $base, $obj->url; - } - - $hashref->{site_used} = $obj->can('service') ? ($obj->service || $obj->cobrand) : $obj->cobrand; - - $hashref->{reported_as} = $obj->get_extra_metadata('contributed_as') || ''; - - if (my $fn = $c->stash->{csv}->{extra_data}) { - my $extra = $fn->($obj); - $hashref = { %$hashref, %$extra }; - } - - $csv->print($c->response, [ - @{$hashref}{ - @{$c->stash->{csv}->{columns}} - }, - ] ); - } -} - sub heatmap : Local : Args(0) { my ($self, $c) = @_; diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index d0f2a9394..e65810b91 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -481,10 +481,10 @@ sub summary : Private { $c->forward('/admin/fetch_contacts'); $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; - $c->forward('/dashboard/construct_rs_filter', []); + my $reporting = $c->forward('/dashboard/construct_rs_filter', []); if ( $c->get_param('csv') ) { - $c->detach('export_summary_csv'); + $c->detach('export_summary_csv', [ $reporting ]); } $c->forward('/dashboard/generate_grouped_data'); @@ -494,38 +494,26 @@ sub summary : Private { } sub export_summary_csv : Private { - my ( $self, $c ) = @_; + my ( $self, $c, $reporting ) = @_; - $c->stash->{csv} = { - objects => $c->stash->{objects_rs}->search_rs({}, { - rows => 100, - order_by => { '-desc' => 'me.confirmed' }, - }), - headers => [ - 'Report ID', - 'Title', - 'Category', - 'Created', - 'Confirmed', - 'Status', - 'Latitude', 'Longitude', - 'Query', - 'Report URL', - ], - columns => [ - 'id', - 'title', - 'category', - 'created', - 'confirmed', - 'state', - 'latitude', 'longitude', - 'postcode', - 'url', - ], - filename => 'fixmystreet-data', - }; - $c->forward('/dashboard/generate_csv'); + $reporting->objects_attrs({ + rows => 100, + order_by => { '-desc' => 'me.confirmed' }, + }); + $reporting->add_csv_columns( + id => 'Report ID', + title => 'Title', + category => 'Category', + created => 'Created', + confirmed => 'Confirmed', + state => 'Status', + latitude => 'Latitude', + longitude => 'Longitude', + postcode => 'Query', + url => 'Report URL', + ); + $reporting->filename('fixmystreet-data'); + $reporting->generate_csv_http($c); } =head2 check_canonical_url diff --git a/perllib/FixMyStreet/Cobrand/BathNES.pm b/perllib/FixMyStreet/Cobrand/BathNES.pm index 2dc1a4297..e8e2c5427 100644 --- a/perllib/FixMyStreet/Cobrand/BathNES.pm +++ b/perllib/FixMyStreet/Cobrand/BathNES.pm @@ -175,20 +175,20 @@ sub _dashboard_user_lookup { sub dashboard_export_updates_add_columns { my ($self, $csv) = @_; - return unless $csv->{user}->has_body_permission_to('export_extra_columns'); + return unless $csv->user->has_body_permission_to('export_extra_columns'); - push @{$csv->{headers}}, "Staff User"; - push @{$csv->{headers}}, "User Email"; - push @{$csv->{columns}}, "staff_user"; - push @{$csv->{columns}}, "user_email"; + $csv->add_csv_columns( + staff_user => 'Staff User', + user_email => 'User Email', + ); - $csv->{objects} = $csv->{objects}->search(undef, { + $csv->objects_attrs({ '+columns' => ['user.email'], join => 'user', }); my $user_lookup = $self->_dashboard_user_lookup; - $csv->{extra_data} = sub { + $csv->csv_extra_data(sub { my $report = shift; my $staff_user = ''; @@ -200,37 +200,28 @@ sub dashboard_export_updates_add_columns { user_email => $report->user->email || '', staff_user => $staff_user, }; - }; + }); } sub dashboard_export_problems_add_columns { my ($self, $csv) = @_; - return unless $csv->{user}->has_body_permission_to('export_extra_columns'); - - $csv->{headers} = [ - @{ $csv->{headers} }, - "User Email", - "User Phone", - "Staff User", - "Attribute Data", - ]; - - $csv->{columns} = [ - @{ $csv->{columns} }, - "user_email", - "user_phone", - "staff_user", - "attribute_data", - ]; - - $csv->{objects} = $csv->{objects}->search(undef, { + return unless $csv->user->has_body_permission_to('export_extra_columns'); + + $csv->add_csv_columns( + user_email => 'User Email', + user_phone => 'User Phone', + staff_user => 'Staff User', + attribute_data => "Attribute Data", + ); + + $csv->objects_attrs({ '+columns' => ['user.email', 'user.phone'], join => 'user', }); my $user_lookup = $self->_dashboard_user_lookup; - $csv->{extra_data} = sub { + $csv->csv_extra_data(sub { my $report = shift; my $staff_user = ''; @@ -244,7 +235,7 @@ sub dashboard_export_problems_add_columns { staff_user => $staff_user, attribute_data => $attribute_data, }; - }; + }); } 1; diff --git a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm index 75356dc87..f901c4e2f 100644 --- a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm +++ b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm @@ -126,8 +126,7 @@ sub default_map_zoom { 3 } sub _dashboard_export_add_columns { my ($self, $csv) = @_; - push @{$csv->{headers}}, "Staff User"; - push @{$csv->{columns}}, "staff_user"; + $csv->add_csv_columns( staff_user => 'Staff User' ); # All staff users, for contributed_by lookup my @user_ids = FixMyStreet::DB->resultset('User')->search( @@ -135,7 +134,7 @@ sub _dashboard_export_add_columns { { columns => [ 'id', 'email', ] })->all; my %user_lookup = map { $_->id => $_->email } @user_ids; - $csv->{extra_data} = sub { + $csv->csv_extra_data(sub { my $report = shift; my $staff_user = ''; if (my $contributed_by = $report->get_extra_metadata('contributed_by')) { @@ -144,7 +143,7 @@ sub _dashboard_export_add_columns { return { staff_user => $staff_user, }; - }; + }); } sub dashboard_export_updates_add_columns { diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm index 6021827cb..1575d7cc1 100644 --- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm +++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm @@ -227,10 +227,9 @@ sub available_permissions { sub dashboard_export_problems_add_columns { my ($self, $csv) = @_; - push @{$csv->{headers}}, "HIAMS/Exor Ref"; - push @{$csv->{columns}}, "external_ref"; + $csv->add_csv_columns( external_ref => 'HIAMS/Exor Ref' ); - $csv->{extra_data} = sub { + $csv->csv_extra_data(sub { my $report = shift; # Try and get a HIAMS reference first of all my $ref = $report->get_extra_metadata('customer_reference'); @@ -243,7 +242,7 @@ sub dashboard_export_problems_add_columns { return { external_ref => ( $ref || '' ), }; - }; + }); } 1; diff --git a/perllib/FixMyStreet/Cobrand/TfL.pm b/perllib/FixMyStreet/Cobrand/TfL.pm index e085b8bd6..31c7e4fe8 100644 --- a/perllib/FixMyStreet/Cobrand/TfL.pm +++ b/perllib/FixMyStreet/Cobrand/TfL.pm @@ -244,38 +244,29 @@ sub available_permissions { sub dashboard_export_problems_add_columns { my ($self, $csv) = @_; - $csv->{headers} = [ - map { $_ eq 'Ward' ? 'Borough' : $_ } @{ $csv->{headers} }, - "Agent responsible", - "Safety critical", - "Delivered to", - "Closure email at", - "Reassigned at", - "Reassigned by", - ]; - - $csv->{columns} = [ - @{ $csv->{columns} }, - "agent_responsible", - "safety_critical", - "delivered_to", - "closure_email_at", - "reassigned_at", - "reassigned_by", - ]; - - if ($csv->{category}) { - my ($contact) = grep { $_->category eq $csv->{category} } @{$csv->{contacts}}; + $csv->modify_csv_header( Ward => 'Borough' ); + + $csv->add_csv_columns( + agent_responsible => "Agent responsible", + safety_critical => "Safety critical", + delivered_to => "Delivered to", + closure_email_at => "Closure email at", + reassigned_at => "Reassigned at", + reassigned_by => "Reassigned by", + ); + + if ($csv->category) { + my @contacts = $csv->body->contacts->search(undef, { order_by => [ 'category' ] } )->all; + my ($contact) = grep { $_->category eq $csv->category } @contacts; if ($contact) { foreach (@{$contact->get_metadata_for_storage}) { next if $_->{code} eq 'safety_critical'; - push @{$csv->{columns}}, "extra.$_->{code}"; - push @{$csv->{headers}}, $_->{description}; + $csv->add_csv_columns( "extra.$_->{code}" => $_->{description} ); } } } - $csv->{extra_data} = sub { + $csv->csv_extra_data(sub { my $report = shift; my $agent = $report->shortlisted_user; @@ -316,7 +307,7 @@ sub dashboard_export_problems_add_columns { $fields->{"extra.$_->{name}"} = $_->{value}; } return $fields; - }; + }); } sub must_have_2fa { diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index e33edc792..5635939ac 100644 --- a/perllib/FixMyStreet/Cobrand/Zurich.pm +++ b/perllib/FixMyStreet/Cobrand/Zurich.pm @@ -1275,8 +1275,8 @@ sub admin_stats { sub export_as_csv { my ($self, $c, $params) = @_; - my $csv = $c->stash->{csv} = { - objects => $c->model('DB::Problem')->search_rs( + my $reporting = FixMyStreet::Reporting->new( + objects_rs => $c->model('DB::Problem')->search_rs( $params, { join => ['admin_log_entries', 'user'], @@ -1297,7 +1297,7 @@ sub export_as_csv { ] } ), - headers => [ + csv_headers => [ 'Report ID', 'Created', 'Sent to Agency', 'Last Updated', 'E', 'N', 'Category', 'Status', 'Closure Status', 'UserID', 'User email', 'User phone', 'User name', @@ -1305,7 +1305,7 @@ sub export_as_csv { 'Media URL', 'Interface Used', 'Council Response', 'Strasse', 'Mast-Nr.', 'Haus-Nr.', 'Hydranten-Nr.', ], - columns => [ + csv_columns => [ 'id', 'created', 'whensent',' lastupdate', 'local_coords_x', 'local_coords_y', 'category', 'state', 'closure_status', 'user_id', 'user_email', 'user_phone', 'user_name', @@ -1313,7 +1313,7 @@ sub export_as_csv { 'media_url', 'service', 'public_response', 'strasse', 'mast_nr',' haus_nr', 'hydranten_nr', ], - extra_data => sub { + csv_extra_data => sub { my $report = shift; my $body_name = ""; @@ -1360,8 +1360,8 @@ sub export_as_csv { }; }, filename => 'stats', - }; - $c->forward('/dashboard/generate_csv'); + ); + $reporting->generate_csv_http($c); } sub problem_confirm_email_extras { diff --git a/perllib/FixMyStreet/Reporting.pm b/perllib/FixMyStreet/Reporting.pm new file mode 100644 index 000000000..7ee9fc41a --- /dev/null +++ b/perllib/FixMyStreet/Reporting.pm @@ -0,0 +1,337 @@ +package FixMyStreet::Reporting; + +use DateTime; +use Moo; +use Text::CSV; +use Types::Standard qw(ArrayRef CodeRef Enum HashRef InstanceOf Int Maybe Str); +use FixMyStreet::DB; + +# What are we reporting on + +has type => ( is => 'ro', isa => Enum['problems','updates'] ); +has on_problems => ( is => 'lazy', default => sub { $_[0]->type eq 'problems' } ); +has on_updates => ( is => 'lazy', default => sub { $_[0]->type eq 'updates' } ); + +# Filters to restrict the reporting to + +has body => ( is => 'ro', isa => Maybe[InstanceOf['FixMyStreet::DB::Result::Body']] ); +has wards => ( is => 'ro', isa => ArrayRef[Int], default => sub { [] } ); +has category => ( is => 'ro', isa => Maybe[Str] ); +has state => ( is => 'ro', isa => Maybe[Str] ); +has start_date => ( is => 'ro', + isa => Str, + default => sub { + my $days30 = DateTime->now(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(days => 30); + $days30->truncate( to => 'day' ); + $days30->strftime('%Y-%m-%d'); + } +); +has end_date => ( is => 'ro', isa => Maybe[Str] ); + +# Things needed for cobrand specific extra data or checks + +has cobrand => ( is => 'ro', default => sub { FixMyStreet::DB->schema->cobrand } ); # Which cobrand is asking, to get the right data / hooks / base URL +has user => ( is => 'ro', isa => Maybe[InstanceOf['FixMyStreet::DB::Result::User']] ); + +# Things created in the process, that can be manually overridden + +has objects_rs => ( is => 'rwp' ); # ResultSet of rows + +sub objects_attrs { + my ($self, $attrs) = @_; + my $rs = $self->objects_rs->search(undef, $attrs); + $self->_set_objects_rs($rs); + return $rs; +} + +# CSV header strings and column keys (looked up in the row's as_hashref, plus +# the following: user_name_display, acknowledged, fixed, closed, wards, +# local_coords_x, local_coords_y, url, subcategory, site_used, reported_as) +has csv_headers => ( is => 'rwp', isa => ArrayRef[Str], default => sub { [] } ); +has csv_columns => ( is => 'rwp', isa => ArrayRef[Str], default => sub { [] } ); + +sub modify_csv_header { + my ($self, %mapping) = @_; + $self->_set_csv_headers([ + map { $mapping{$_} || $_ } @{ $self->csv_headers }, + ]); +} + +sub add_csv_columns { + my $self = shift; + for (my $i = 0; $i < @_; $i += 2) { + my $column = $_[$i]; + my $header = $_[$i+1]; + push @{$self->csv_columns}, $column; + push @{$self->csv_headers}, $header; + } +} + +# A function that is passed the report and returns a hashref of extra data to +# include that can be used by 'columns' +has csv_extra_data => ( is => 'rw', isa => CodeRef ); + +has filename => ( is => 'rw', isa => Str, lazy => 1, default => sub { + my $self = shift; + my %where = ( + category => $self->category, + state => $self->state, + ward => join(',', @{$self->wards}), + start_date => $self->start_date, + end_date => $self->end_date, + ); + $where{body} = $self->body->id if $self->body; + my $host = URI->new($self->cobrand->base_url)->host; + join '-', + $host, + $self->on_updates ? ('updates') : (), + map { + my $value = $where{$_}; + (defined $value and length $value) ? ($_, $value) : () + } sort keys %where +}); + +# Generation code + +sub construct_rs_filter { + my $self = shift; + + my $table_name = $self->on_updates ? 'problem' : 'me'; + + my %where; + $where{areas} = [ map { { 'like', "%,$_,%" } } @{$self->wards} ] + if @{$self->wards}; + $where{"$table_name.category"} = $self->category + if $self->category; + + if ( $self->state && FixMyStreet::DB::Result::Problem->fixed_states->{$self->state} ) { # Probably fixed - council + $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; + } elsif ( $self->state ) { + $where{"$table_name.state"} = $self->state; + } else { + $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->visible_states() ]; + } + + my $range = FixMyStreet::DateRange->new( + start_date => $self->start_date, + end_date => $self->end_date, + formatter => FixMyStreet::DB->schema->storage->datetime_parser, + ); + $where{"$table_name.confirmed"} = $range->sql; + + my $rs = $self->on_updates ? $self->cobrand->updates : $self->cobrand->problems; + my $objects_rs = $rs->to_body($self->body)->search( \%where ); + $self->_set_objects_rs($objects_rs); + return { + params => \%where, + objects_rs => $objects_rs, + } +} + +sub csv_parameters { + my $self = shift; + if ($self->on_updates) { + $self->_csv_parameters_updates; + } else { + $self->_csv_parameters_problems; + } +} + +sub _csv_parameters_updates { + my $self = shift; + + $self->objects_attrs({ + join => 'problem', + order_by => ['me.confirmed', 'me.id'], + '+columns' => ['problem.bodies_str'], + cursor_page_size => 1000, + }); + $self->_set_csv_headers([ + 'Report ID', 'Update ID', 'Date', 'Status', 'Problem state', + 'Text', 'User Name', 'Reported As', + ]); + $self->_set_csv_columns([ + 'problem_id', 'id', 'confirmed', 'state', 'problem_state', + 'text', 'user_name_display', 'reported_as', + ]); + $self->cobrand->call_hook(dashboard_export_updates_add_columns => $self); +} + +sub _csv_parameters_problems { + my $self = shift; + + my $groups = $self->cobrand->enable_category_groups ? 1 : 0; + my $join = ['comments']; + my $columns = ['comments.id', 'comments.problem_state', 'comments.state', 'comments.confirmed', 'comments.mark_fixed']; + if ($groups) { + push @$join, 'contact'; + push @$columns, 'contact.id', 'contact.extra'; + } + $self->objects_attrs({ + join => $join, + collapse => 1, + '+columns' => $columns, + order_by => ['me.confirmed', 'me.id'], + cursor_page_size => 1000, + }); + $self->_set_csv_headers([ + 'Report ID', + 'Title', + 'Detail', + 'User Name', + 'Category', + $groups ? ('Subcategory') : (), + 'Created', + 'Confirmed', + 'Acknowledged', + 'Fixed', + 'Closed', + 'Status', + 'Latitude', 'Longitude', + 'Query', + 'Ward', + 'Easting', + 'Northing', + 'Report URL', + 'Site Used', + 'Reported As', + ]); + $self->_set_csv_columns([ + 'id', + 'title', + 'detail', + 'user_name_display', + 'category', + $groups ? ('subcategory') : (), + 'created', + 'confirmed', + 'acknowledged', + 'fixed', + 'closed', + 'state', + 'latitude', 'longitude', + 'postcode', + 'wards', + 'local_coords_x', + 'local_coords_y', + 'url', + 'site_used', + 'reported_as', + ]); + $self->cobrand->call_hook(dashboard_export_problems_add_columns => $self); +} + +=head2 generate_csv + +Generates a CSV output to a file handler provided + +=cut + +sub generate_csv { + my ($self, $handle) = @_; + + my $csv = Text::CSV->new({ binary => 1, eol => "\n" }); + $csv->print($handle, $self->csv_headers); + + my $fixed_states = FixMyStreet::DB::Result::Problem->fixed_states; + my $closed_states = FixMyStreet::DB::Result::Problem->closed_states; + + my %asked_for = map { $_ => 1 } @{$self->csv_columns}; + + my $children = $self->body ? $self->body->first_area_children : {}; + + my $objects = $self->objects_rs; + while ( my $obj = $objects->next ) { + my $hashref = $obj->as_hashref(\%asked_for); + + $hashref->{user_name_display} = $obj->anonymous + ? '(anonymous)' : $obj->name; + + if ($asked_for{acknowledged}) { + for my $comment ($obj->comments) { + my $problem_state = $comment->problem_state or next; + next unless $comment->state eq 'confirmed'; + next if $problem_state eq 'confirmed'; + $hashref->{acknowledged} //= $comment->confirmed; + $hashref->{fixed} //= $fixed_states->{ $problem_state } || $comment->mark_fixed ? + $comment->confirmed : undef; + if ($closed_states->{ $problem_state }) { + $hashref->{closed} = $comment->confirmed; + last; + } + } + } + + if ($asked_for{wards}) { + $hashref->{wards} = join ', ', + map { $children->{$_}->{name} } + grep { $children->{$_} } + split ',', $hashref->{areas}; + } + + if ($obj->can('local_coords') && $asked_for{local_coords_x}) { + ($hashref->{local_coords_x}, $hashref->{local_coords_y}) = + $obj->local_coords; + } + + if ($asked_for{subcategory}) { + my $group = $obj->contact ? $obj->contact->groups : []; + $group = join(',', @$group); + if ($group) { + $hashref->{subcategory} = $obj->category; + $hashref->{category} = $group; + } + } + + my $base = $self->cobrand->base_url_for_report($obj->can('problem') ? $obj->problem : $obj); + $hashref->{url} = join '', $base, $obj->url; + + $hashref->{site_used} = $obj->can('service') ? ($obj->service || $obj->cobrand) : $obj->cobrand; + + $hashref->{reported_as} = $obj->get_extra_metadata('contributed_as') || ''; + + if (my $fn = $self->csv_extra_data) { + my $extra = $fn->($obj); + $hashref = { %$hashref, %$extra }; + } + + $csv->print($handle, [ + @{$hashref}{ + @{$self->csv_columns} + }, + ] ); + } +} + +# Output code + +# Outputs relevant CSV HTTP headers, and then streams the CSV +sub generate_csv_http { + my ($self, $c) = @_; + $self->http_setup($c); + $self->generate_csv($c->response); +} + +sub http_setup { + my ($self, $c) = @_; + my $filename = $self->filename; + + $c->res->content_type('text/csv; charset=utf-8'); + $c->res->header('content-disposition' => "attachment; filename=\"${filename}.csv\""); + + # Emit a header (copying Drupal's naming) telling an intermediary (e.g. + # Varnish) not to buffer the output. Varnish will need to know this, e.g.: + # if (beresp.http.Surrogate-Control ~ "BigPipe/1.0") { + # set beresp.do_stream = true; + # set beresp.ttl = 0s; + # } + $c->res->header('Surrogate-Control' => 'content="BigPipe/1.0"'); + + # Tell nginx not to buffer this response + $c->res->header('X-Accel-Buffering' => 'no'); + + # Define an empty body so the web view doesn't get added at the end + $c->res->body(""); +} + +1; |