aboutsummaryrefslogtreecommitdiffstats
path: root/perllib/FixMyStreet/Reporting.pm
diff options
context:
space:
mode:
Diffstat (limited to 'perllib/FixMyStreet/Reporting.pm')
-rw-r--r--perllib/FixMyStreet/Reporting.pm388
1 files changed, 388 insertions, 0 deletions
diff --git a/perllib/FixMyStreet/Reporting.pm b/perllib/FixMyStreet/Reporting.pm
new file mode 100644
index 000000000..efd12718c
--- /dev/null
+++ b/perllib/FixMyStreet/Reporting.pm
@@ -0,0 +1,388 @@
+package FixMyStreet::Reporting;
+
+use DateTime;
+use Moo;
+use Path::Tiny;
+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 splice_csv_column {
+ my ($self, $before, $column, $header) = @_;
+
+ for (my $i = 0; $i < @{$self->csv_columns}; $i++) {
+ my $col = $self->csv_columns->[$i];
+ if ($col eq $before) {
+ splice @{$self->csv_columns}, $i, 0, $column;
+ splice @{$self->csv_headers}, $i, 0, $header;
+ last;
+ }
+ }
+}
+
+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{$_};
+ (my $nosp = $value || '') =~ s/ /-/g;
+ (defined $value and length $value) ? ($_, $nosp) : ()
+ } 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->{action_scheduled} //= $problem_state eq 'action scheduled' ? $comment->confirmed : undef;
+ $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
+
+sub cache_dir {
+ my $self = shift;
+
+ my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS');
+ my $dir = $cfg ? $cfg->{UPLOAD_DIR} : FixMyStreet->config('UPLOAD_DIR');
+ $dir = path($dir, "dashboard_csv")->absolute(FixMyStreet->path_to());
+ my $subdir = $self->user ? $self->user->id : 0;
+ $dir = $dir->child($subdir);
+ $dir->mkpath;
+ $dir;
+}
+
+sub kick_off_process {
+ my $self = shift;
+
+ my $out = path($self->cache_dir, $self->filename . '.csv');
+ my $file = path($out . '-part');
+ return if $file->exists;
+ $file->touch; # So status page shows it even if process takes short while to spin up
+
+ my $cmd = FixMyStreet->path_to('bin/csv-export');
+ $cmd .= ' --cobrand ' . $self->cobrand->moniker;
+ $cmd .= " --out \Q$out\E";
+ foreach (qw(type category state start_date end_date)) {
+ $cmd .= " --$_ " . quotemeta($self->$_) if $self->$_;
+ }
+ foreach (qw(body user)) {
+ $cmd .= " --$_ " . $self->$_->id if $self->$_;
+ }
+ $cmd .= " --wards " . join(',', map { quotemeta } @{$self->wards}) if @{$self->wards};
+ $cmd .= ' &' unless FixMyStreet->test_mode;
+
+ system($cmd);
+}
+
+# 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;