diff options
Diffstat (limited to 'perllib/FixMyStreet')
123 files changed, 5170 insertions, 2819 deletions
diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index 82fcce508..36f736cd2 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -10,10 +10,12 @@ use Memcached; use FixMyStreet::Map; use FixMyStreet::Email; use FixMyStreet::Email::Sender; +use FixMyStreet::PhotoStorage; use Utils; use Path::Tiny 'path'; use Try::Tiny; +use Text::CSV; use URI; use URI::QueryParam; @@ -34,6 +36,11 @@ our $VERSION = '0.01'; __PACKAGE__->config( + # Use REQUEST_URI, not PATH_INFO, to infer path. This fixes an issue + # with slashes in category names in admin (as PATH_INFO can't tell + # the difference between / and %2F) + use_request_uri_for_path => 1, + # get the config from the core object %{ FixMyStreet->config() }, @@ -127,11 +134,9 @@ after 'prepare_headers' => sub { __PACKAGE__->log->disable('debug') # unless __PACKAGE__->debug; -# Check upload_dir -my $cache_dir = path(FixMyStreet->config('UPLOAD_DIR'))->absolute(FixMyStreet->path_to()); -$cache_dir->mkpath; -unless ( -d $cache_dir && -w $cache_dir ) { - warn "\x1b[31mCan't find/write to photo cache directory '$cache_dir'\x1b[0m\n"; +# Set up photo storage +unless ( FixMyStreet::PhotoStorage::backend->init() ) { + warn "\x1b[31mCan't set up photo storage backend\x1b[0m\n"; } =head1 NAME @@ -237,9 +242,9 @@ sub setup_request { $c->stash->{map_js} = FixMyStreet::Map::map_javascript(); unless ( FixMyStreet->config('MAPIT_URL') ) { - my $port = $c->req->uri->port; - $host = "$host:$port" unless $port == 80; - mySociety::MaPit::configure( "http://$host/fakemapit/" ); + my $host_port = $c->req->uri->host_port; + my $scheme = $c->req->uri->scheme; + mySociety::MaPit::configure( "$scheme://$host_port/fakemapit/" ); } $c->stash->{has_fixed_state} = FixMyStreet::DB::Result::Problem::fixed_states->{fixed}; @@ -420,27 +425,6 @@ sub uri_with { return $uri; } -=head2 uri_for - - $uri = $c->uri_for( ... ); - -Like C<uri_for> except that it passes the uri to the cobrand to be altered if -needed. - -=cut - -sub uri_for { - my $c = shift; - my @args = @_; - - my $uri = $c->next::method(@args); - - my $cobranded_uri = $c->cobrand->uri($uri); - - # note that the returned uri may be a string not an object (eg cities) - return $cobranded_uri; -} - =head2 uri_for_email $uri = $c->uri_for_email( ... ); @@ -517,7 +501,11 @@ sub get_param_list { my $value = $c->req->params->{$param}; return () unless defined $value; my @value = ref $value ? @$value : ($value); - return map { split /,/, $_ } @value if $allow_commas; + if ($allow_commas) { + my $csv = Text::CSV->new; + $csv->parse(join ',', @value); + @value = $csv->fields; + } return @value; } diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index 7d04f5ff9..2f4669456 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -13,6 +13,7 @@ use List::Util 'first'; use List::MoreUtils 'uniq'; use mySociety::ArrayUtils; use Text::CSV; +use Try::Tiny; use FixMyStreet::SendReport; use FixMyStreet::SMS; @@ -142,353 +143,51 @@ sub timeline : Path( 'timeline' ) : Args(0) { my %time; - $c->model('DB')->schema->storage->sql_maker->quote_char( '"' ); - $c->model('DB')->schema->storage->sql_maker->name_sep( '.' ); + try { + $c->model('DB')->schema->storage->sql_maker->quote_char( '"' ); + $c->model('DB')->schema->storage->sql_maker->name_sep( '.' ); - my $probs = $c->cobrand->problems->timeline; + my $probs = $c->cobrand->problems->timeline; - foreach ($probs->all) { - push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ }; - push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed; - push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent; - } - - my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction ); - - foreach ($questionnaires->all) { - push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ }; - push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered; - } - - my $updates = $c->cobrand->updates->timeline; - - foreach ($updates->all) { - push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ; - } - - my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction ); - - foreach ($alerts->all) { - push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ }; - } - - $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction ); - - foreach ($alerts->all) { - push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ }; - } - - $c->model('DB')->schema->storage->sql_maker->quote_char( '' ); - - $c->stash->{time} = \%time; - - return 1; -} - -sub bodies : Path('bodies') : Args(0) { - my ( $self, $c ) = @_; - - if (my $body_id = $c->get_param('body')) { - return $c->res->redirect( $c->uri_for( 'body', $body_id ) ); - } - - if (!$c->user->is_superuser && $c->user->from_body && $c->cobrand->moniker ne 'zurich') { - return $c->res->redirect( $c->uri_for( 'body', $c->user->from_body->id ) ); - } - - $c->forward( '/auth/get_csrf_token' ); - - my $edit_activity = $c->model('DB::ContactsHistory')->search( - undef, - { - select => [ 'editor', { count => 'contacts_history_id', -as => 'c' } ], - as => [ 'editor', 'c' ], - group_by => ['editor'], - order_by => { -desc => 'c' } + foreach ($probs->all) { + push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ }; + push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed; + push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent; } - ); - - $c->stash->{edit_activity} = $edit_activity; - $c->forward( 'fetch_languages' ); - $c->forward( 'fetch_translations' ); + my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction ); - my $posted = $c->get_param('posted') || ''; - if ( $posted eq 'body' ) { - $c->forward('check_for_super_user'); - $c->forward('/auth/check_csrf_token'); - - my $values = $c->forward('body_params'); - unless ( keys %{$c->stash->{body_errors}} ) { - my $body = $c->model('DB::Body')->create( $values->{params} ); - if ($values->{extras}) { - $body->set_extra_metadata( $_ => $values->{extras}->{$_} ) - for keys %{$values->{extras}}; - $body->update; - } - my @area_ids = $c->get_param_list('area_ids'); - foreach (@area_ids) { - $c->model('DB::BodyArea')->create( { body => $body, area_id => $_ } ); - } - - $c->stash->{object} = $body; - $c->stash->{translation_col} = 'name'; - $c->forward('update_translations'); - $c->stash->{updated} = _('New body added'); + foreach ($questionnaires->all) { + push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ }; + push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered; } - } - $c->forward( 'fetch_all_bodies' ); + my $updates = $c->cobrand->updates->timeline; - my $contacts = $c->model('DB::Contact')->search( - undef, - { - select => [ 'body_id', { count => 'id' }, { count => \'case when state = \'deleted\' then 1 else null end' }, - { count => \'case when state = \'confirmed\' then 1 else null end' } ], - as => [qw/body_id c deleted confirmed/], - group_by => [ 'body_id' ], - result_class => 'DBIx::Class::ResultClass::HashRefInflator' + foreach ($updates->all) { + push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ; } - ); - - my %council_info = map { $_->{body_id} => $_ } $contacts->all; - - $c->stash->{counts} = \%council_info; - - $c->forward( 'body_form_dropdowns' ); - - return 1; -} - -sub body_form_dropdowns : Private { - my ( $self, $c ) = @_; - - my $areas; - my $whitelist = $c->config->{MAPIT_ID_WHITELIST}; - - if ( $whitelist && ref $whitelist eq 'ARRAY' && @$whitelist ) { - $areas = mySociety::MaPit::call('areas', $whitelist); - } else { - $areas = mySociety::MaPit::call('areas', $c->cobrand->area_types); - } - - # Some cobrands may want to add extra areas at runtime beyond those - # available via MAPIT_WHITELIST or MAPIT_TYPES. This can be used for, - # e.g., parish councils on a particular council cobrand. - $areas = $c->cobrand->call_hook("add_extra_areas" => $areas) || $areas; - - $c->stash->{areas} = [ sort { strcoll($a->{name}, $b->{name}) } values %$areas ]; - - my @methods = map { $_ =~ s/FixMyStreet::SendReport:://; $_ } sort keys %{ FixMyStreet::SendReport->get_senders }; - $c->stash->{send_methods} = \@methods; -} - -sub check_for_super_user : Private { - my ( $self, $c ) = @_; - - my $superuser = $c->user->is_superuser; - # Zurich currently has its own way of defining superusers - $superuser ||= $c->cobrand->moniker eq 'zurich' && $c->stash->{admin_type} eq 'super'; - - unless ( $superuser ) { - $c->detach('/page_error_403_access_denied', []); - } -} -sub update_contacts : Private { - my ( $self, $c ) = @_; - - my $posted = $c->get_param('posted'); - my $editor = $c->forward('get_user'); - - if ( $posted eq 'new' ) { - $c->forward('/auth/check_csrf_token'); - - my %errors; - - my $category = $self->trim( $c->get_param('category') ); - $errors{category} = _("Please choose a category") unless $category; - $errors{note} = _('Please enter a message') unless $c->get_param('note'); - - my $contact = $c->model('DB::Contact')->find_or_new( - { - body_id => $c->stash->{body_id}, - category => $category, - } - ); - - my $email = $c->get_param('email'); - $email =~ s/\s+//g; - my $send_method = $c->get_param('send_method') || $contact->send_method || $contact->body->send_method || ""; - unless ( $send_method eq 'Open311' ) { - $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED'; - } + my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction ); - $contact->email( $email ); - $contact->state( $c->get_param('state') ); - $contact->non_public( $c->get_param('non_public') ? 1 : 0 ); - $contact->note( $c->get_param('note') ); - $contact->whenedited( \'current_timestamp' ); - $contact->editor( $editor ); - $contact->endpoint( $c->get_param('endpoint') ); - $contact->jurisdiction( $c->get_param('jurisdiction') ); - $contact->api_key( $c->get_param('api_key') ); - $contact->send_method( $c->get_param('send_method') ); - - # Set flags in extra to the appropriate values - if ( $c->get_param('photo_required') ) { - $contact->set_extra_metadata_if_undefined( photo_required => 1 ); - } - else { - $contact->unset_extra_metadata( 'photo_required' ); - } - if ( $c->get_param('inspection_required') ) { - $contact->set_extra_metadata( inspection_required => 1 ); - } - else { - $contact->unset_extra_metadata( 'inspection_required' ); - } - if ( $c->get_param('reputation_threshold') ) { - $contact->set_extra_metadata( reputation_threshold => int($c->get_param('reputation_threshold')) ); + foreach ($alerts->all) { + push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ }; } - $c->forward('update_extra_fields', [ $contact ]); - $c->forward('contact_cobrand_extra_fields', [ $contact ]); + $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction ); - if ( %errors ) { - $c->stash->{updated} = _('Please correct the errors below'); - $c->stash->{contact} = $contact; - $c->stash->{errors} = \%errors; - } elsif ( $contact->in_storage ) { - $c->stash->{updated} = _('Values updated'); - - # NB: History is automatically stored by a trigger in the database - $contact->update; - } else { - $c->stash->{updated} = _('New category contact added'); - $contact->insert; + foreach ($alerts->all) { + push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ }; } + } catch { + die $_; + } finally { + $c->model('DB')->schema->storage->sql_maker->quote_char( '' ); + }; - unless ( %errors ) { - $c->stash->{translation_col} = 'category'; - $c->stash->{object} = $contact; - $c->forward('update_translations'); - } - - } elsif ( $posted eq 'update' ) { - $c->forward('/auth/check_csrf_token'); - - my @categories = $c->get_param_list('confirmed'); - - my $contacts = $c->model('DB::Contact')->search( - { - body_id => $c->stash->{body_id}, - category => { -in => \@categories }, - } - ); - - $contacts->update( - { - state => 'confirmed', - whenedited => \'current_timestamp', - note => 'Confirmed', - editor => $editor, - } - ); - - $c->stash->{updated} = _('Values updated'); - } elsif ( $posted eq 'body' ) { - $c->forward('check_for_super_user'); - $c->forward('/auth/check_csrf_token'); - - my $values = $c->forward( 'body_params' ); - unless ( keys %{$c->stash->{body_errors}} ) { - $c->stash->{body}->update( $values->{params} ); - if ($values->{extras}) { - $c->stash->{body}->set_extra_metadata( $_ => $values->{extras}->{$_} ) - for keys %{$values->{extras}}; - $c->stash->{body}->update; - } - my @current = $c->stash->{body}->body_areas->all; - my %current = map { $_->area_id => 1 } @current; - my @area_ids = $c->get_param_list('area_ids'); - foreach (@area_ids) { - $c->model('DB::BodyArea')->find_or_create( { body => $c->stash->{body}, area_id => $_ } ); - delete $current{$_}; - } - # Remove any others - $c->stash->{body}->body_areas->search( { area_id => [ keys %current ] } )->delete; - - $c->stash->{translation_col} = 'name'; - $c->stash->{object} = $c->stash->{body}; - $c->forward('update_translations'); - - $c->stash->{updated} = _('Values updated'); - } - } -} - -sub update_translations : Private { - my ( $self, $c ) = @_; - - foreach my $lang (keys(%{$c->stash->{languages}})) { - my $id = $c->get_param('translation_id_' . $lang); - my $text = $c->get_param('translation_' . $lang); - if ($id) { - my $translation = $c->model('DB::Translation')->find( - { - id => $id, - } - ); - - if ($text) { - $translation->msgstr($text); - $translation->update; - } else { - $translation->delete; - } - } elsif ($text) { - my $col = $c->stash->{translation_col}; - $c->stash->{object}->add_translation_for( - $col, $lang, $text - ); - } - } -} - -sub body_params : Private { - my ( $self, $c ) = @_; - - my @fields = qw/name endpoint jurisdiction api_key send_method external_url/; - my %defaults = map { $_ => '' } @fields; - %defaults = ( %defaults, - send_comments => 0, - fetch_problems => 0, - convert_latlong => 0, - blank_updates_permitted => 0, - suppress_alerts => 0, - comment_user_id => undef, - send_extended_statuses => 0, - can_be_devolved => 0, - parent => undef, - deleted => 0, - ); - my %params = map { $_ => $c->get_param($_) || $defaults{$_} } keys %defaults; - $c->forward('check_body_params', [ \%params ]); - my @extras = qw/fetch_all_problems/; - %defaults = map { $_ => '' } @extras; - my %extras = map { $_ => $c->get_param($_) || $defaults{$_} } @extras; - return { params => \%params, extras => \%extras }; -} - -sub check_body_params : Private { - my ( $self, $c, $params ) = @_; - - $c->stash->{body_errors} ||= {}; + $c->stash->{time} = \%time; - unless ($params->{name}) { - $c->stash->{body_errors}->{name} = _('Please enter a name for this body'); - } + return 1; } sub fetch_contacts : Private { @@ -522,125 +221,6 @@ sub fetch_languages : Private { return 1; } -sub fetch_translations : Private { - my ( $self, $c ) = @_; - - my $translations = {}; - if ($c->get_param('posted')) { - foreach my $lang (keys %{$c->stash->{languages}}) { - if (my $msgstr = $c->get_param('translation_' . $lang)) { - $translations->{$lang} = { msgstr => $msgstr }; - } - if (my $id = $c->get_param('translation_id_' . $lang)) { - $translations->{$lang}->{id} = $id; - } - } - } elsif ($c->stash->{object}) { - my @translations = $c->stash->{object}->translation_for($c->stash->{translation_col})->all; - - foreach my $tx (@translations) { - $translations->{$tx->lang} = { id => $tx->id, msgstr => $tx->msgstr }; - } - } - - $c->stash->{translations} = $translations; -} - -sub lookup_body : Private { - my ( $self, $c, $body_id ) = @_; - - $c->stash->{body_id} = $body_id; - my $body = $c->model('DB::Body')->find($body_id); - $c->detach( '/page_error_404_not_found', [] ) - unless $body; - $c->stash->{body} = $body; -} - -sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { - my ( $self, $c, $body_id ) = @_; - - $c->forward('lookup_body'); - my $body = $c->stash->{body}; - - if ($body->body_areas->first) { - my $example_postcode = mySociety::MaPit::call('area/example_postcode', $body->body_areas->first->area_id); - if ($example_postcode && ! ref $example_postcode) { - $c->stash->{example_pc} = $example_postcode; - } - } -} - -sub edit_body : Chained('body') : PathPart('') : Args(0) { - my ( $self, $c ) = @_; - - unless ($c->user->has_permission_to('category_edit', $c->stash->{body_id})) { - $c->forward('check_for_super_user'); - } - - $c->forward( '/auth/get_csrf_token' ); - $c->forward( 'fetch_all_bodies' ); - $c->forward( 'body_form_dropdowns' ); - $c->forward('fetch_languages'); - - if ( $c->get_param('posted') ) { - $c->forward('update_contacts'); - } - - $c->stash->{object} = $c->stash->{body}; - $c->stash->{translation_col} = 'name'; - - # if there's a contact then it's because we're displaying error - # messages about adding a contact so grabbing translations will - # fetch the contact submitted translations. So grab them, stash - # them and then clear posted so we can fetch the body translations - if ($c->stash->{contact}) { - $c->forward('fetch_translations'); - $c->stash->{contact_translations} = $c->stash->{translations}; - } - $c->set_param('posted', ''); - - $c->forward('fetch_translations'); - - # don't set this last as fetch_contacts might over-ride it - # to display email addresses as text - $c->stash->{template} = 'admin/body.html'; - $c->forward('fetch_contacts'); - - return 1; -} - -sub category : Chained('body') : PathPart('') { - my ( $self, $c, @category ) = @_; - my $category = join( '/', @category ); - - $c->forward( '/auth/get_csrf_token' ); - $c->stash->{template} = 'admin/category_edit.html'; - - my $contact = $c->stash->{body}->contacts->search( { category => $category } )->first; - $c->stash->{contact} = $contact; - - $c->stash->{translation_col} = 'category'; - $c->stash->{object} = $c->stash->{contact}; - - $c->forward('fetch_languages'); - $c->forward('fetch_translations'); - - my $history = $c->model('DB::ContactsHistory')->search( - { - body_id => $c->stash->{body_id}, - category => $c->stash->{contact}->category - }, - { - order_by => ['contacts_history_id'] - }, - ); - $c->stash->{history} = $history; - my @methods = map { $_ =~ s/FixMyStreet::SendReport:://; $_ } sort keys %{ FixMyStreet::SendReport->get_senders }; - $c->stash->{send_methods} = \@methods; - - return 1; -} - sub reports : Path('reports') { my ( $self, $c ) = @_; @@ -672,6 +252,15 @@ sub reports : Path('reports') { return if $c->cobrand->call_hook(report_search_query => $query, $p_page, $u_page, $order); if (my $search = $c->get_param('search')) { + $search = $self->trim($search); + + # In case an email address, wrapped in <...> + if ($search =~ /^<(.*)>$/) { + my $possible_email = $1; + my $parsed = FixMyStreet::SMS->parse_username($possible_email); + $search = $possible_email if $parsed->{email}; + } + $c->stash->{searched} = $search; my $search_n = 0; @@ -775,7 +364,7 @@ sub reports : Path('reports') { -select => [ 'me.*', qw/problem.bodies_str problem.state/ ], prefetch => [qw/problem/], rows => 50, - order_by => [ \"(me.state='hidden')", \"(problem.state='hidden')", 'me.created' ] + order_by => [ \"(me.state='hidden')", \"(problem.state='hidden')", { -desc => 'me.created' } ] } )->page( $u_page ); $c->stash->{updates} = [ $updates->all ]; @@ -822,6 +411,7 @@ sub report_edit_display : Private { longitude => $problem->longitude, colour => $c->cobrand->pin_colour($problem, 'admin'), type => 'big', + draggable => 1, } ] : [], print_report => 1, @@ -870,11 +460,13 @@ sub report_edit : Path('report_edit') : Args(1) { $c->forward('categories_for_point'); + $c->forward('alerts_for_report'); + $c->forward('check_username_for_abuse', [ $problem->user ] ); $c->stash->{updates} = [ $c->model('DB::Comment') - ->search( { problem_id => $problem->id }, { order_by => 'created' } ) + ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } ) ->all ]; if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { @@ -890,7 +482,7 @@ sub report_edit : Path('report_edit') : Args(1) { if ( $c->get_param('resend') ) { $c->forward('/auth/check_csrf_token'); - $problem->whensent(undef); + $problem->resend; $problem->update(); $c->stash->{status_message} = '<p><em>' . _('That problem will now be resent.') . '</em></p>'; @@ -904,15 +496,15 @@ sub report_edit : Path('report_edit') : Args(1) { $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] ); } elsif ( $c->get_param('flaguser') ) { - $c->forward('flag_user'); + $c->forward('users/flag'); $c->stash->{problem}->discard_changes; } elsif ( $c->get_param('removeuserflag') ) { - $c->forward('remove_user_flag'); + $c->forward('users/flag_remove'); $c->stash->{problem}->discard_changes; } elsif ( $c->get_param('banuser') ) { - $c->forward('ban_user'); + $c->forward('users/ban'); } elsif ( $c->get_param('submit') ) { $c->forward('/auth/check_csrf_token'); @@ -957,10 +549,9 @@ sub report_edit : Path('report_edit') : Args(1) { if ( $problem->state ne $old_state ) { $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] ); - my $name = _('an administrator'); + my $name = $c->user->moderating_user_name; my $extra = { is_superuser => 1 }; if ($c->user->from_body) { - $name = $c->user->from_body->name; delete $extra->{is_superuser}; $extra->{is_body_user} = $c->user->from_body->id; } @@ -994,6 +585,7 @@ sub report_edit : Path('report_edit') : Args(1) { =head2 report_edit_category Handles changing a problem's category and the complexity that comes with it. +Returns 1 if category changed, 0 if no change. =cut @@ -1008,7 +600,7 @@ sub report_edit_category : Private { # If the report has changed bodies (and not to a subset!) we need to resend it my %old_map = map { $_ => 1 } @{$problem->bodies_str_ids}; if (grep !$old_map{$_}, @new_body_ids) { - $problem->whensent(undef); + $problem->resend; } # If the send methods of the old/new contacts differ we need to resend the report my @new_send_methods = uniq map { @@ -1019,7 +611,7 @@ sub report_edit_category : Private { } @contacts; my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); if (grep !$old_send_methods{$_}, @new_send_methods) { - $problem->whensent(undef); + $problem->resend; } $problem->bodies_str(join( ',', @new_body_ids )); @@ -1038,7 +630,9 @@ sub report_edit_category : Private { anonymous => 0, }); } + return 1; } + return 0; } =head2 report_edit_location @@ -1047,7 +641,8 @@ Handles changing a problem's location and the complexity that comes with it. For now, we reject the new location if the new location and old locations aren't covered by the same body. -Returns 1 if the new position (if any) is acceptable, undef otherwise. +Returns 2 if the new position (if any) is acceptable and changed, +1 if acceptable and unchanged, undef otherwise. NB: This must be called before report_edit_category, as that might modify $problem->bodies_str. @@ -1067,6 +662,8 @@ sub report_edit_location : Private { # this lookup is bad. So let's save the stash and restore it after the # comparison. my $safe_stash = { %{$c->stash} }; + $c->stash->{fetch_all_areas} = 1; + $c->stash->{area_check_action} = 'admin'; $c->forward('/council/load_and_check_areas', []); $c->forward('/report/new/setup_categories_and_bodies'); my %allowed_bodies = map { $_ => 1 } @{$problem->bodies_str_ids}; @@ -1076,6 +673,9 @@ sub report_edit_location : Private { return unless $bodies_match; $problem->latitude($c->stash->{latitude}); $problem->longitude($c->stash->{longitude}); + my $areas = $c->stash->{all_areas_mapit}; + $problem->areas( ',' . join( ',', sort keys %$areas ) . ',' ); + return 2; } return 1; } @@ -1098,6 +698,17 @@ sub categories_for_point : Private { $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} }; } +sub alerts_for_report : Private { + my ($self, $c) = @_; + + $c->stash->{alert_count} = $c->model('DB::Alert')->search({ + alert_type => 'new_updates', + parameter => $c->stash->{report}->id, + confirmed => 1, + whendisabled => undef, + })->count(); +} + sub templates : Path('templates') : Args(0) { my ( $self, $c ) = @_; @@ -1249,46 +860,6 @@ sub load_template_body : Private { or $c->detach( '/page_error_404_not_found', [] ); } -sub users: Path('users') : Args(0) { - my ( $self, $c ) = @_; - - if (my $search = $c->get_param('search')) { - $c->stash->{searched} = $search; - - my $isearch = '%' . $search . '%'; - my $search_n = 0; - $search_n = int($search) if $search =~ /^\d+$/; - - my $users = $c->cobrand->users->search( - { - -or => [ - email => { ilike => $isearch }, - phone => { ilike => $isearch }, - name => { ilike => $isearch }, - from_body => $search_n, - ] - } - ); - my @users = $users->all; - $c->stash->{users} = [ @users ]; - $c->forward('add_flags', [ { email => { ilike => $isearch } } ]); - - } else { - $c->forward('/auth/get_csrf_token'); - $c->forward('fetch_all_bodies'); - - # Admin users by default - my $users = $c->cobrand->users->search( - { from_body => { '!=', undef } }, - { order_by => 'name' } - ); - my @users = $users->all; - $c->stash->{users} = \@users; - } - - return 1; -} - sub update_edit : Path('update_edit') : Args(1) { my ( $self, $c, $id ) = @_; @@ -1309,14 +880,14 @@ sub update_edit : Path('update_edit') : Args(1) { $c->forward('check_username_for_abuse', [ $update->user ] ); if ( $c->get_param('banuser') ) { - $c->forward('ban_user'); + $c->forward('users/ban'); } elsif ( $c->get_param('flaguser') ) { - $c->forward('flag_user'); + $c->forward('users/flag'); $c->stash->{update}->discard_changes; } elsif ( $c->get_param('removeuserflag') ) { - $c->forward('remove_user_flag'); + $c->forward('users/flag_remove'); $c->stash->{update}->discard_changes; } elsif ( $c->get_param('submit') ) { @@ -1383,366 +954,6 @@ sub update_edit : Path('update_edit') : Args(1) { return 1; } -sub phone_check : Private { - my ($self, $c, $phone) = @_; - my $parsed = FixMyStreet::SMS->parse_username($phone); - if ($parsed->{phone} && $parsed->{may_be_mobile}) { - return $parsed->{username}; - } elsif ($parsed->{phone}) { - $c->stash->{field_errors}->{phone} = _('Please enter a mobile number'); - } else { - $c->stash->{field_errors}->{phone} = _('Please check your phone number is correct'); - } -} - -sub user_add : Path('user_edit') : Args(0) { - my ( $self, $c ) = @_; - - $c->stash->{template} = 'admin/user_edit.html'; - $c->forward('/auth/get_csrf_token'); - $c->forward('fetch_all_bodies'); - - return unless $c->get_param('submit'); - - $c->forward('/auth/check_csrf_token'); - - $c->stash->{field_errors} = {}; - my $email = lc $c->get_param('email'); - my $phone = $c->get_param('phone'); - my $email_v = $c->get_param('email_verified'); - my $phone_v = $c->get_param('phone_verified'); - - unless ($email || $phone) { - $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); - } - if (!$email_v && !$phone_v) { - $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); - } - if ($email && !is_valid_email($email)) { - $c->stash->{field_errors}->{email} = _('Please enter a valid email'); - } - unless ($c->get_param('name')) { - $c->stash->{field_errors}->{name} = _('Please enter a name'); - } - - if ($phone_v) { - my $parsed_phone = $c->forward('phone_check', [ $phone ]); - $phone = $parsed_phone if $parsed_phone; - } - - my $existing_email = $email_v && $c->model('DB::User')->find( { email => $email } ); - my $existing_phone = $phone_v && $c->model('DB::User')->find( { phone => $phone } ); - if ($existing_email || $existing_phone) { - $c->stash->{field_errors}->{username} = _('User already exists'); - } - - return if %{$c->stash->{field_errors}}; - - my $user = $c->model('DB::User')->create( { - name => $c->get_param('name'), - email => $email ? $email : undef, - email_verified => $email && $email_v ? 1 : 0, - phone => $phone || undef, - phone_verified => $phone && $phone_v ? 1 : 0, - from_body => $c->get_param('body') || undef, - flagged => $c->get_param('flagged') || 0, - # Only superusers can create superusers - is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0, - } ); - $c->stash->{user} = $user; - $c->forward('user_cobrand_extra_fields'); - $user->update; - - $c->forward( 'log_edit', [ $user->id, 'user', 'edit' ] ); - - $c->flash->{status_message} = _("Updated!"); - $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); -} - -sub user_edit : Path('user_edit') : Args(1) { - my ( $self, $c, $id ) = @_; - - $c->forward('/auth/get_csrf_token'); - - my $user = $c->cobrand->users->find( { id => $id } ); - $c->detach( '/page_error_404_not_found', [] ) unless $user; - - unless ( $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) { - $c->detach('/page_error_403_access_denied', []); - } - - $c->stash->{user} = $user; - $c->forward( 'check_username_for_abuse', [ $user ] ); - - if ( $user->from_body && $c->user->has_permission_to('user_manage_permissions', $user->from_body->id) ) { - $c->stash->{available_permissions} = $c->cobrand->available_permissions; - } - - $c->forward('fetch_all_bodies'); - $c->forward('fetch_body_areas', [ $user->from_body ]) if $user->from_body; - - if ( defined $c->flash->{status_message} ) { - $c->stash->{status_message} = - '<p><em>' . $c->flash->{status_message} . '</em></p>'; - } - - $c->forward('/auth/check_csrf_token') if $c->get_param('submit'); - - if ( $c->get_param('submit') and $c->get_param('unban') ) { - $c->forward('unban_user', [ $user ]); - } elsif ( $c->get_param('submit') and $c->get_param('logout_everywhere') ) { - $c->forward('user_logout_everywhere', [ $user ]); - } elsif ( $c->get_param('submit') and $c->get_param('anon_everywhere') ) { - $c->forward('user_anon_everywhere', [ $user ]); - } elsif ( $c->get_param('submit') and $c->get_param('hide_everywhere') ) { - $c->forward('user_hide_everywhere', [ $user ]); - } elsif ( $c->get_param('submit') and $c->get_param('remove_account') ) { - $c->forward('user_remove_account', [ $user ]); - } elsif ( $c->get_param('submit') ) { - - my $edited = 0; - - my $name = $c->get_param('name'); - my $email = lc $c->get_param('email'); - my $phone = $c->get_param('phone'); - my $email_v = $c->get_param('email_verified') || 0; - my $phone_v = $c->get_param('phone_verified') || 0; - - $c->stash->{field_errors} = {}; - - unless ($email || $phone) { - $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); - } - if (!$email_v && !$phone_v) { - $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); - } - if ($email && !is_valid_email($email)) { - $c->stash->{field_errors}->{email} = _('Please enter a valid email'); - } - - if ($phone_v) { - my $parsed_phone = $c->forward('phone_check', [ $phone ]); - $phone = $parsed_phone if $parsed_phone; - } - - unless ($name) { - $c->stash->{field_errors}->{name} = _('Please enter a name'); - } - - my $email_params = { email => $email, email_verified => 1, id => { '!=', $user->id } }; - my $phone_params = { phone => $phone, phone_verified => 1, id => { '!=', $user->id } }; - my $existing_email = $email_v && $c->model('DB::User')->search($email_params)->first; - my $existing_phone = $phone_v && $c->model('DB::User')->search($phone_params)->first; - my $existing_user = $existing_email || $existing_phone; - my $existing_email_cobrand = $email_v && $c->cobrand->users->search($email_params)->first; - my $existing_phone_cobrand = $phone_v && $c->cobrand->users->search($phone_params)->first; - my $existing_user_cobrand = $existing_email_cobrand || $existing_phone_cobrand; - if ($existing_phone_cobrand && $existing_email_cobrand && $existing_email_cobrand->id != $existing_phone_cobrand->id) { - $c->stash->{field_errors}->{username} = _('User already exists'); - } - - return if %{$c->stash->{field_errors}}; - - if ( ($user->email || "") ne $email || - $user->name ne $name || - ($user->phone || "") ne $phone || - ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) || - (!$user->from_body && $c->get_param('body')) - ) { - $edited = 1; - } - - if ($existing_user_cobrand) { - $existing_user->adopt($user); - $c->forward( 'log_edit', [ $id, 'user', 'merge' ] ); - return $c->res->redirect( $c->uri_for( 'user_edit', $existing_user->id ) ); - } - - $user->email($email) if !$existing_email; - $user->phone($phone) if !$existing_phone; - $user->email_verified( $email_v ); - $user->phone_verified( $phone_v ); - $user->name( $name ); - - $user->flagged( $c->get_param('flagged') || 0 ); - # Only superusers can grant superuser status - $user->is_superuser( ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0 ); - # Superusers can set from_body to any value, but other staff can only - # set from_body to the same value as their own from_body. - if ( $c->user->is_superuser || $c->cobrand->moniker eq 'zurich' ) { - $user->from_body( $c->get_param('body') || undef ); - } elsif ( $c->user->has_body_permission_to('user_assign_body') && - $c->get_param('body') && $c->get_param('body') eq $c->user->from_body->id ) { - $user->from_body( $c->user->from_body ); - } else { - $user->from_body( undef ); - } - - $c->forward('user_cobrand_extra_fields'); - - # Has the user's from_body changed since we fetched areas (if we ever did)? - # If so, we need to re-fetch areas so the UI is up to date. - if ( $user->from_body && $user->from_body->id ne $c->stash->{fetched_areas_body_id} ) { - $c->forward('fetch_body_areas', [ $user->from_body ]); - } - - if (!$user->from_body) { - # Non-staff users aren't allowed any permissions or to be in an area - $user->admin_user_body_permissions->delete; - $user->area_id(undef); - delete $c->stash->{areas}; - delete $c->stash->{fetched_areas_body_id}; - } elsif ($c->stash->{available_permissions}) { - my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} }; - my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions; - $user->admin_user_body_permissions->search({ - body_id => $user->from_body->id, - permission_type => { '!=' => \@user_permissions }, - })->delete; - foreach my $permission_type (@user_permissions) { - $user->user_body_permissions->find_or_create({ - body_id => $user->from_body->id, - permission_type => $permission_type, - }); - } - } - - if ( $user->from_body && $c->user->has_permission_to('user_assign_areas', $user->from_body->id) ) { - my %valid_areas = map { $_->{id} => 1 } @{ $c->stash->{areas} }; - my $new_area = $c->get_param('area_id'); - $user->area_id( $valid_areas{$new_area} ? $new_area : undef ); - } - - # Handle 'trusted' flag(s) - my @trusted_bodies = $c->get_param_list('trusted_bodies'); - if ( $c->user->is_superuser ) { - $user->user_body_permissions->search({ - body_id => { '!=' => \@trusted_bodies }, - permission_type => 'trusted', - })->delete; - foreach my $body_id (@trusted_bodies) { - $user->user_body_permissions->find_or_create({ - body_id => $body_id, - permission_type => 'trusted', - }); - } - } elsif ( $c->user->from_body ) { - my %trusted = map { $_ => 1 } @trusted_bodies; - my $body_id = $c->user->from_body->id; - if ( $trusted{$body_id} ) { - $user->user_body_permissions->find_or_create({ - body_id => $body_id, - permission_type => 'trusted', - }); - } else { - $user->user_body_permissions->search({ - body_id => $body_id, - permission_type => 'trusted', - })->delete; - } - } - - # Update the categories this user operates in - if ( $user->from_body ) { - $c->stash->{body} = $user->from_body; - $c->forward('fetch_contacts'); - my @live_contacts = $c->stash->{live_contacts}->all; - my @live_contact_ids = map { $_->id } @live_contacts; - my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; - $user->set_extra_metadata('categories', \@new_contact_ids); - } - - $user->update; - if ($edited) { - $c->forward( 'log_edit', [ $id, 'user', 'edit' ] ); - } - $c->flash->{status_message} = _("Updated!"); - return $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); - } - - if ( $user->from_body ) { - unless ( $c->stash->{live_contacts} ) { - $c->stash->{body} = $user->from_body; - $c->forward('fetch_contacts'); - } - my @contacts = @{$user->get_extra_metadata('categories') || []}; - my %active_contacts = map { $_ => 1 } @contacts; - my @live_contacts = $c->stash->{live_contacts}->all; - my @all_contacts = map { { - id => $_->id, - category => $_->category, - active => $active_contacts{$_->id}, - } } @live_contacts; - $c->stash->{contacts} = \@all_contacts; - } - - return 1; -} - -sub user_import : Path('user_import') { - my ( $self, $c, $id ) = @_; - - $c->forward('/auth/get_csrf_token'); - return unless $c->user_exists && $c->user->is_superuser; - - if ($c->req->method eq 'POST') { - $c->forward('/auth/check_csrf_token'); - $c->stash->{new_users} = []; - $c->stash->{existing_users} = []; - - my @all_permissions = map { keys %$_ } values %{ $c->cobrand->available_permissions }; - my %available_permissions = map { $_ => 1 } @all_permissions; - - my $csv = Text::CSV->new({ binary => 1}); - my $fh = $c->req->upload('csvfile')->fh; - $csv->getline($fh); # discard the header - while (my $row = $csv->getline($fh)) { - my ($name, $email, $from_body, $permissions) = @$row; - $email = lc Utils::trim_text($email); - my @permissions = split(/:/, $permissions); - - my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 }); - if ($user->in_storage) { - push @{$c->stash->{existing_users}}, $user; - next; - } - - $user->name($name); - $user->from_body($from_body || undef); - $user->update_or_insert; - - my @user_permissions = grep { $available_permissions{$_} } @permissions; - foreach my $permission_type (@user_permissions) { - $user->user_body_permissions->find_or_create({ - body_id => $user->from_body->id, - permission_type => $permission_type, - }); - } - - push @{$c->stash->{new_users}}, $user; - } - - } -} - -sub contact_cobrand_extra_fields : Private { - my ( $self, $c, $contact ) = @_; - - my $extra_fields = $c->cobrand->call_hook('contact_extra_fields'); - foreach ( @$extra_fields ) { - $contact->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); - } -} - -sub user_cobrand_extra_fields : Private { - my ( $self, $c ) = @_; - - my @extra_fields = @{ $c->cobrand->call_hook('user_extra_fields') || [] }; - foreach ( @extra_fields ) { - $c->stash->{user}->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); - } -} - sub add_flags : Private { my ( $self, $c, $search ) = @_; @@ -1841,165 +1052,6 @@ sub log_edit : Private { )->insert(); } -=head2 ban_user - -Add the user's email address/phone number to the abuse table if they are not -already in there and sets status_message accordingly. - -=cut - -sub ban_user : Private { - my ( $self, $c ) = @_; - - my $user; - if ($c->stash->{problem}) { - $user = $c->stash->{problem}->user; - } elsif ($c->stash->{update}) { - $user = $c->stash->{update}->user; - } - return unless $user; - - if ($user->email_verified && $user->email) { - my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->email }); - if ( $abuse->in_storage ) { - $c->stash->{status_message} = _('User already in abuse list'); - } else { - $abuse->insert; - $c->stash->{status_message} = _('User added to abuse list'); - } - $c->stash->{username_in_abuse} = 1; - } - if ($user->phone_verified && $user->phone) { - my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->phone }); - if ( $abuse->in_storage ) { - $c->stash->{status_message} = _('User already in abuse list'); - } else { - $abuse->insert; - $c->stash->{status_message} = _('User added to abuse list'); - } - $c->stash->{username_in_abuse} = 1; - } - return 1; -} - -sub user_logout_everywhere : Private { - my ( $self, $c, $user ) = @_; - my $sessions = $user->get_extra_metadata('sessions'); - foreach (grep { $_ ne $c->sessionid } @$sessions) { - $c->delete_session_data("session:$_"); - } - $c->stash->{status_message} = _('That user has been logged out.'); -} - -sub user_anon_everywhere : Private { - my ( $self, $c, $user ) = @_; - $user->problems->update({anonymous => 1}); - $user->comments->update({anonymous => 1}); - $c->stash->{status_message} = _('That user has been made anonymous on all reports and updates.'); -} - -sub user_hide_everywhere : Private { - my ( $self, $c, $user ) = @_; - my $problems = $user->problems->search({ state => { '!=' => 'hidden' } }); - while (my $problem = $problems->next) { - $problem->get_photoset->delete_cached; - $problem->update({ state => 'hidden' }); - } - my $updates = $user->comments->search({ state => { '!=' => 'hidden' } }); - while (my $update = $updates->next) { - $update->hide; - } - $c->stash->{status_message} = _('That user’s reports and updates have been hidden.'); -} - -# Anonymize and remove name from all problems/updates, disable all alerts. -# Remove their account's email address, phone number, password, etc. -sub user_remove_account : Private { - my ( $self, $c, $user ) = @_; - $c->forward('user_logout_everywhere', [ $user ]); - $user->anonymize_account; - $c->stash->{status_message} = _('That user’s personal details have been removed.'); -} - -sub unban_user : Private { - my ( $self, $c, $user ) = @_; - - my @username; - if ($user->email_verified && $user->email) { - push @username, $user->email; - } - if ($user->phone_verified && $user->phone) { - push @username, $user->phone; - } - if (@username) { - my $abuse = $c->model('DB::Abuse')->search({ email => \@username }); - if ( $abuse ) { - $abuse->delete; - $c->stash->{status_message} = _('user removed from abuse list'); - } else { - $c->stash->{status_message} = _('user not in abuse list'); - } - $c->stash->{username_in_abuse} = 0; - } -} - -=head2 flag_user - -Sets the flag on a user - -=cut - -sub flag_user : Private { - my ( $self, $c ) = @_; - - my $user; - if ($c->stash->{problem}) { - $user = $c->stash->{problem}->user; - } elsif ($c->stash->{update}) { - $user = $c->stash->{update}->user; - } - - if ( !$user ) { - $c->stash->{status_message} = _('Could not find user'); - } else { - $user->flagged(1); - $user->update; - $c->stash->{status_message} = _('User flagged'); - } - - $c->stash->{user_flagged} = 1; - - return 1; -} - -=head2 remove_user_flag - -Remove the flag on a user - -=cut - -sub remove_user_flag : Private { - my ( $self, $c ) = @_; - - my $user; - if ($c->stash->{problem}) { - $user = $c->stash->{problem}->user; - } elsif ($c->stash->{update}) { - $user = $c->stash->{update}->user; - } - - if ( !$user ) { - $c->stash->{status_message} = _('Could not find user'); - } else { - $user->flagged(0); - $user->update; - $c->stash->{status_message} = _('User flag removed'); - } - - return 1; -} - - =head2 check_username_for_abuse $c->forward('check_username_for_abuse', [ $user ] ); @@ -2101,10 +1153,15 @@ sub check_page_allowed : Private { sub fetch_all_bodies : Private { my ($self, $c ) = @_; - my @bodies = $c->model('DB::Body')->translated->all_sorted; - if ( $c->cobrand->moniker eq 'zurich' ) { - @bodies = $c->cobrand->admin_fetch_all_bodies( @bodies ); + my @bodies = $c->cobrand->call_hook('admin_fetch_all_bodies'); + if (!@bodies) { + my $bodies = $c->model('DB::Body')->search(undef, { + columns => [ "id", "name", "deleted", "parent" ], + })->with_parent_name; + $bodies = $bodies->with_defect_type_count if $c->stash->{with_defect_type_count}; + @bodies = $bodies->translated->all_sorted; } + $c->stash->{bodies} = \@bodies; return 1; @@ -2145,6 +1202,8 @@ sub update_extra_fields : Private { $meta->{variable} = $notice ? 'false' : 'true'; $meta->{description} = $c->get_param("metadata[$i].description"); $meta->{datatype_description} = $c->get_param("metadata[$i].datatype_description"); + $meta->{automated} = $c->get_param("metadata[$i].automated") + if $c->get_param("metadata[$i].automated"); if ( $meta->{datatype} eq "singlevaluelist" ) { $meta->{values} = []; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm new file mode 100644 index 000000000..0e47d2238 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm @@ -0,0 +1,476 @@ +package FixMyStreet::App::Controller::Admin::Bodies; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use POSIX qw(strcoll); +use mySociety::EmailUtil qw(is_valid_email_list); +use FixMyStreet::MapIt; +use FixMyStreet::SendReport; + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Bodies - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages + +=head1 METHODS + +=cut + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + if (my $body_id = $c->get_param('body')) { + return $c->res->redirect( $c->uri_for_action('admin/bodies/edit', [ $body_id ] ) ); + } + + if (!$c->user->is_superuser && $c->user->from_body && $c->cobrand->moniker ne 'zurich') { + return $c->res->redirect( $c->uri_for_action('admin/bodies/edit', [ $c->user->from_body->id ] ) ); + } + + $c->forward( '/auth/get_csrf_token' ); + + my $edit_activity = $c->model('DB::ContactsHistory')->search( + undef, + { + select => [ 'editor', { count => 'contacts_history_id', -as => 'c' } ], + as => [ 'editor', 'c' ], + group_by => ['editor'], + order_by => { -desc => 'c' } + } + ); + + $c->stash->{edit_activity} = $edit_activity; + + $c->forward( '/admin/fetch_languages' ); + $c->forward( 'fetch_translations' ); + + my $posted = $c->get_param('posted') || ''; + if ( $posted eq 'body' ) { + $c->forward('check_for_super_user'); + $c->forward('/auth/check_csrf_token'); + + my $values = $c->forward('body_params'); + unless ( keys %{$c->stash->{body_errors}} ) { + my $body = $c->model('DB::Body')->create( $values->{params} ); + if ($values->{extras}) { + $body->set_extra_metadata( $_ => $values->{extras}->{$_} ) + for keys %{$values->{extras}}; + $body->update; + } + my @area_ids = $c->get_param_list('area_ids'); + foreach (@area_ids) { + $c->model('DB::BodyArea')->create( { body => $body, area_id => $_ } ); + } + + $c->stash->{object} = $body; + $c->stash->{translation_col} = 'name'; + $c->forward('update_translations'); + $c->stash->{updated} = _('New body added'); + } + } + + $c->forward( '/admin/fetch_all_bodies' ); + + my $contacts = $c->model('DB::Contact')->search( + undef, + { + select => [ 'body_id', { count => 'id' }, { count => \'case when state = \'deleted\' then 1 else null end' }, + { count => \'case when state = \'confirmed\' then 1 else null end' } ], + as => [qw/body_id c deleted confirmed/], + group_by => [ 'body_id' ], + result_class => 'DBIx::Class::ResultClass::HashRefInflator' + } + ); + + my %council_info = map { $_->{body_id} => $_ } $contacts->all; + + $c->stash->{counts} = \%council_info; + + $c->forward( 'body_form_dropdowns' ); + + return 1; +} + +sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { + my ( $self, $c, $body_id ) = @_; + + $c->stash->{body_id} = $body_id; + my $body = $c->model('DB::Body')->find($body_id); + $c->detach( '/page_error_404_not_found', [] ) unless $body; + $c->stash->{body} = $body; + + if ($body->body_areas->first) { + my $example_postcode = FixMyStreet::MapIt::call('area/example_postcode', $body->body_areas->first->area_id); + if ($example_postcode && ! ref $example_postcode) { + $c->stash->{example_pc} = $example_postcode; + } + } +} + +sub edit : Chained('body') : PathPart('') : Args(0) { + my ( $self, $c ) = @_; + + unless ($c->user->has_permission_to('category_edit', $c->stash->{body_id})) { + $c->forward('check_for_super_user'); + } + + $c->forward( '/auth/get_csrf_token' ); + $c->forward( '/admin/fetch_all_bodies' ); + $c->forward( 'body_form_dropdowns' ); + $c->forward('/admin/fetch_languages'); + + if ( $c->get_param('posted') ) { + $c->forward('update_contacts'); + } + + $c->stash->{object} = $c->stash->{body}; + $c->stash->{translation_col} = 'name'; + + # if there's a contact then it's because we're displaying error + # messages about adding a contact so grabbing translations will + # fetch the contact submitted translations. So grab them, stash + # them and then clear posted so we can fetch the body translations + if ($c->stash->{contact}) { + $c->forward('fetch_translations'); + $c->stash->{contact_translations} = $c->stash->{translations}; + } + $c->set_param('posted', ''); + + $c->forward('fetch_translations'); + + # don't set this last as fetch_contacts might over-ride it + # to display email addresses as text + $c->stash->{template} = 'admin/bodies/body.html'; + $c->forward('/admin/fetch_contacts'); + + return 1; +} + +sub category : Chained('body') : PathPart('') { + my ( $self, $c, @category ) = @_; + my $category = join( '/', @category ); + + $c->forward( '/auth/get_csrf_token' ); + + my $contact = $c->stash->{body}->contacts->search( { category => $category } )->first; + $c->stash->{contact} = $contact; + + $c->stash->{translation_col} = 'category'; + $c->stash->{object} = $c->stash->{contact}; + + $c->forward('/admin/fetch_languages'); + $c->forward('fetch_translations'); + + my $history = $c->model('DB::ContactsHistory')->search( + { + body_id => $c->stash->{body_id}, + category => $c->stash->{contact}->category + }, + { + order_by => ['contacts_history_id'] + }, + ); + $c->stash->{history} = $history; + my @methods = map { $_ =~ s/FixMyStreet::SendReport:://; $_ } sort keys %{ FixMyStreet::SendReport->get_senders }; + $c->stash->{send_methods} = \@methods; + + return 1; +} + +sub body_form_dropdowns : Private { + my ( $self, $c ) = @_; + + my $areas; + my $whitelist = $c->config->{MAPIT_ID_WHITELIST}; + + if ( $whitelist && ref $whitelist eq 'ARRAY' && @$whitelist ) { + $areas = FixMyStreet::MapIt::call('areas', $whitelist); + } else { + $areas = FixMyStreet::MapIt::call('areas', $c->cobrand->area_types); + } + + # Some cobrands may want to add extra areas at runtime beyond those + # available via MAPIT_WHITELIST or MAPIT_TYPES. This can be used for, + # e.g., parish councils on a particular council cobrand. + $areas = $c->cobrand->call_hook("add_extra_areas" => $areas) || $areas; + + $c->stash->{areas} = [ sort { strcoll($a->{name}, $b->{name}) } values %$areas ]; + + my @methods = map { $_ =~ s/FixMyStreet::SendReport:://; $_ } sort keys %{ FixMyStreet::SendReport->get_senders }; + $c->stash->{send_methods} = \@methods; +} + +sub check_for_super_user : Private { + my ( $self, $c ) = @_; + + my $superuser = $c->user->is_superuser; + # Zurich currently has its own way of defining superusers + $superuser ||= $c->cobrand->moniker eq 'zurich' && $c->stash->{admin_type} eq 'super'; + + unless ( $superuser ) { + $c->detach('/page_error_403_access_denied', []); + } +} + +sub update_contacts : Private { + my ( $self, $c ) = @_; + + my $posted = $c->get_param('posted'); + my $editor = $c->forward('/admin/get_user'); + + if ( $posted eq 'new' ) { + $c->forward('/auth/check_csrf_token'); + + my %errors; + + my $category = $self->trim( $c->get_param('category') ); + $errors{category} = _("Please choose a category") unless $category; + $errors{note} = _('Please enter a message') unless $c->get_param('note'); + + my $contact = $c->model('DB::Contact')->find_or_new( + { + body_id => $c->stash->{body_id}, + category => $category, + } + ); + + my $email = $c->get_param('email'); + $email =~ s/\s+//g; + my $send_method = $c->get_param('send_method') || $contact->send_method || $contact->body->send_method || ""; + unless ( $send_method eq 'Open311' ) { + $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED'; + } + + $contact->email( $email ); + $contact->state( $c->get_param('state') ); + $contact->non_public( $c->get_param('non_public') ? 1 : 0 ); + $contact->note( $c->get_param('note') ); + $contact->whenedited( \'current_timestamp' ); + $contact->editor( $editor ); + $contact->endpoint( $c->get_param('endpoint') ); + $contact->jurisdiction( $c->get_param('jurisdiction') ); + $contact->api_key( $c->get_param('api_key') ); + $contact->send_method( $c->get_param('send_method') ); + + # Set flags in extra to the appropriate values + if ( $c->get_param('photo_required') ) { + $contact->set_extra_metadata_if_undefined( photo_required => 1 ); + } + else { + $contact->unset_extra_metadata( 'photo_required' ); + } + if ( $c->get_param('inspection_required') ) { + $contact->set_extra_metadata( inspection_required => 1 ); + } + else { + $contact->unset_extra_metadata( 'inspection_required' ); + } + if ( $c->get_param('reputation_threshold') ) { + $contact->set_extra_metadata( reputation_threshold => int($c->get_param('reputation_threshold')) ); + } + if ( my $group = $c->get_param('group') ) { + $contact->set_extra_metadata( group => $group ); + } else { + $contact->unset_extra_metadata( 'group' ); + } + + + $c->forward('/admin/update_extra_fields', [ $contact ]); + $c->forward('contact_cobrand_extra_fields', [ $contact ]); + + if ( %errors ) { + $c->stash->{updated} = _('Please correct the errors below'); + $c->stash->{contact} = $contact; + $c->stash->{errors} = \%errors; + } elsif ( $contact->in_storage ) { + $c->stash->{updated} = _('Values updated'); + + # NB: History is automatically stored by a trigger in the database + $contact->update; + } else { + $c->stash->{updated} = _('New category contact added'); + $contact->insert; + } + + unless ( %errors ) { + $c->stash->{translation_col} = 'category'; + $c->stash->{object} = $contact; + $c->forward('update_translations'); + } + + } elsif ( $posted eq 'update' ) { + $c->forward('/auth/check_csrf_token'); + + my @categories = $c->get_param_list('confirmed'); + + my $contacts = $c->model('DB::Contact')->search( + { + body_id => $c->stash->{body_id}, + category => { -in => \@categories }, + } + ); + + $contacts->update( + { + state => 'confirmed', + whenedited => \'current_timestamp', + note => 'Confirmed', + editor => $editor, + } + ); + + $c->stash->{updated} = _('Values updated'); + } elsif ( $posted eq 'body' ) { + $c->forward('check_for_super_user'); + $c->forward('/auth/check_csrf_token'); + + my $values = $c->forward( 'body_params' ); + unless ( keys %{$c->stash->{body_errors}} ) { + $c->stash->{body}->update( $values->{params} ); + if ($values->{extras}) { + $c->stash->{body}->set_extra_metadata( $_ => $values->{extras}->{$_} ) + for keys %{$values->{extras}}; + $c->stash->{body}->update; + } + my @current = $c->stash->{body}->body_areas->all; + my %current = map { $_->area_id => 1 } @current; + my @area_ids = $c->get_param_list('area_ids'); + foreach (@area_ids) { + $c->model('DB::BodyArea')->find_or_create( { body => $c->stash->{body}, area_id => $_ } ); + delete $current{$_}; + } + # Remove any others + $c->stash->{body}->body_areas->search( { area_id => [ keys %current ] } )->delete; + + $c->stash->{translation_col} = 'name'; + $c->stash->{object} = $c->stash->{body}; + $c->forward('update_translations'); + + $c->stash->{updated} = _('Values updated'); + } + } +} + +sub body_params : Private { + my ( $self, $c ) = @_; + + my @fields = qw/name endpoint jurisdiction api_key send_method external_url/; + my %defaults = map { $_ => '' } @fields; + %defaults = ( %defaults, + send_comments => 0, + fetch_problems => 0, + convert_latlong => 0, + blank_updates_permitted => 0, + suppress_alerts => 0, + comment_user_id => undef, + send_extended_statuses => 0, + can_be_devolved => 0, + parent => undef, + deleted => 0, + ); + my %params = map { $_ => $c->get_param($_) || $defaults{$_} } keys %defaults; + $c->forward('check_body_params', [ \%params ]); + my @extras = qw/fetch_all_problems/; + %defaults = map { $_ => '' } @extras; + my %extras = map { $_ => $c->get_param($_) || $defaults{$_} } @extras; + return { params => \%params, extras => \%extras }; +} + +sub check_body_params : Private { + my ( $self, $c, $params ) = @_; + + $c->stash->{body_errors} ||= {}; + + unless ($params->{name}) { + $c->stash->{body_errors}->{name} = _('Please enter a name for this body'); + } +} + +sub contact_cobrand_extra_fields : Private { + my ( $self, $c, $contact ) = @_; + + my $extra_fields = $c->cobrand->call_hook('contact_extra_fields'); + foreach ( @$extra_fields ) { + $contact->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); + } +} + +sub fetch_translations : Private { + my ( $self, $c ) = @_; + + my $translations = {}; + if ($c->get_param('posted')) { + foreach my $lang (keys %{$c->stash->{languages}}) { + if (my $msgstr = $c->get_param('translation_' . $lang)) { + $translations->{$lang} = { msgstr => $msgstr }; + } + if (my $id = $c->get_param('translation_id_' . $lang)) { + $translations->{$lang}->{id} = $id; + } + } + } elsif ($c->stash->{object}) { + my @translations = $c->stash->{object}->translation_for($c->stash->{translation_col})->all; + + foreach my $tx (@translations) { + $translations->{$tx->lang} = { id => $tx->id, msgstr => $tx->msgstr }; + } + } + + $c->stash->{translations} = $translations; +} + +sub update_translations : Private { + my ( $self, $c ) = @_; + + foreach my $lang (keys(%{$c->stash->{languages}})) { + my $id = $c->get_param('translation_id_' . $lang); + my $text = $c->get_param('translation_' . $lang); + if ($id) { + my $translation = $c->model('DB::Translation')->find( + { + id => $id, + } + ); + + if ($text) { + $translation->msgstr($text); + $translation->update; + } else { + $translation->delete; + } + } elsif ($text) { + my $col = $c->stash->{translation_col}; + $c->stash->{object}->add_translation_for( + $col, $lang, $text + ); + } + } +} + +sub trim { + my $self = shift; + my $e = shift; + $e =~ s/^\s+//; + $e =~ s/\s+$//; + return $e; +} + +=head1 AUTHOR + +Struan Donald + +=head1 LICENSE + +This library is free software. You can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm index 5dab1da2c..ed9b40fd0 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm @@ -12,6 +12,7 @@ sub index : Path : Args(0) { my $user = $c->user; if ($user->is_superuser) { + $c->stash->{with_defect_type_count} = 1; $c->forward('/admin/fetch_all_bodies'); } elsif ( $user->from_body ) { $c->forward('load_user_body', [ $user->from_body->id ]); diff --git a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm index d965dd8f2..0026acb9c 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm @@ -5,6 +5,7 @@ use namespace::autoclean; use DateTime; use Try::Tiny; use FixMyStreet::Integrations::ExorRDI; +use FixMyStreet::DateRange; BEGIN { extends 'Catalyst::Controller'; } @@ -43,15 +44,16 @@ sub download : Path('download') : Args(0) { $c->detach( '/page_error_404_not_found', [] ); } - my $parser = DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' ); - my $start_date = $parser-> parse_datetime ( $c->get_param('start_date') ); - my $end_date = $parser-> parse_datetime ( $c->get_param('end_date') ) ; - my $one_day = DateTime::Duration->new( days => 1 ); + my $range = FixMyStreet::DateRange->new( + start_date => $c->get_param('start_date'), + end_date => $c->get_param('end_date'), + parser => DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' ), + ); my $params = { - start_date => $start_date, - inspection_date => $start_date, - end_date => $end_date + $one_day, + start_date => $range->start, + inspection_date => $range->start, + end_date => $range->end, user => $c->get_param('user_id'), mark_as_processed => 0, }; diff --git a/perllib/FixMyStreet/App/Controller/Admin/ReportExtraFields.pm b/perllib/FixMyStreet/App/Controller/Admin/ReportExtraFields.pm index 337fb4bed..0ddbb01f7 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/ReportExtraFields.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/ReportExtraFields.pm @@ -9,7 +9,7 @@ BEGIN { extends 'Catalyst::Controller'; } sub index : Path : Args(0) { my ( $self, $c ) = @_; - my @extras = $c->model('DB::ReportExtraFields')->search( + my @extras = $c->model('DB::ReportExtraField')->search( undef, { order_by => 'name' @@ -24,9 +24,9 @@ sub edit : Path : Args(1) { my $extra; if ( $extra_id eq 'new' ) { - $extra = $c->model('DB::ReportExtraFields')->new({}); + $extra = $c->model('DB::ReportExtraField')->new({}); } else { - $extra = $c->model('DB::ReportExtraFields')->find( $extra_id ) + $extra = $c->model('DB::ReportExtraField')->find( $extra_id ) or $c->detach( '/page_error_404_not_found' ); } diff --git a/perllib/FixMyStreet/App/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm new file mode 100644 index 000000000..bcbc808ed --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm @@ -0,0 +1,685 @@ +package FixMyStreet::App::Controller::Admin::Users; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use POSIX qw(strcoll); +use mySociety::EmailUtil qw(is_valid_email); +use Text::CSV; + +use FixMyStreet::MapIt; +use FixMyStreet::SMS; +use Utils; + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Users - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages for editing users + +=head1 METHODS + +=cut + +sub index :Path : Args(0) { + my ( $self, $c ) = @_; + + $c->detach('add') if $c->req->method eq 'POST'; # Add a user + + if (my $search = $c->get_param('search')) { + $search = $self->trim($search); + $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...> + $c->stash->{searched} = $search; + + my $isearch = '%' . $search . '%'; + my $search_n = 0; + $search_n = int($search) if $search =~ /^\d+$/; + + my $users = $c->cobrand->users->search( + { + -or => [ + email => { ilike => $isearch }, + phone => { ilike => $isearch }, + name => { ilike => $isearch }, + from_body => $search_n, + ] + } + ); + my @users = $users->all; + $c->stash->{users} = [ @users ]; + $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]); + + } else { + $c->forward('/auth/get_csrf_token'); + $c->forward('/admin/fetch_all_bodies'); + $c->cobrand->call_hook('admin_user_edit_extra_data'); + + + # Admin users by default + my $users = $c->cobrand->users->search( + { from_body => { '!=', undef } }, + { order_by => 'name' } + ); + my @users = $users->all; + $c->stash->{users} = \@users; + } + + return 1; +} + +sub add : Local : Args(0) { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'admin/users/edit.html'; + $c->forward('/auth/get_csrf_token'); + $c->forward('/admin/fetch_all_bodies'); + $c->cobrand->call_hook('admin_user_edit_extra_data'); + + return unless $c->get_param('submit'); + + $c->forward('/auth/check_csrf_token'); + + $c->stash->{field_errors} = {}; + my $email = lc $c->get_param('email'); + my $phone = $c->get_param('phone'); + my $email_v = $c->get_param('email_verified'); + my $phone_v = $c->get_param('phone_verified'); + + if ($email && !is_valid_email($email)) { + $c->stash->{field_errors}->{email} = _('Please enter a valid email'); + } + unless ($c->get_param('name')) { + $c->stash->{field_errors}->{name} = _('Please enter a name'); + } + + unless ($email || $phone) { + $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); + } + if (!$email_v && !$phone_v) { + $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); + } + + if ($phone_v) { + my $parsed_phone = $c->forward('phone_check', [ $phone ]); + $phone = $parsed_phone if $parsed_phone; + } + + my $existing_email = $email_v && $c->model('DB::User')->find( { email => $email } ); + my $existing_phone = $phone_v && $c->model('DB::User')->find( { phone => $phone } ); + if ($existing_email || $existing_phone) { + $c->stash->{field_errors}->{username} = _('User already exists'); + } + + return if %{$c->stash->{field_errors}}; + + my $user = $c->model('DB::User')->create( { + name => $c->get_param('name'), + email => $email ? $email : undef, + email_verified => $email && $email_v ? 1 : 0, + phone => $phone || undef, + phone_verified => $phone && $phone_v ? 1 : 0, + from_body => $c->get_param('body') || undef, + flagged => $c->get_param('flagged') || 0, + # Only superusers can create superusers + is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0, + } ); + $c->stash->{user} = $user; + $c->forward('user_cobrand_extra_fields'); + $user->update; + + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); + + $c->flash->{status_message} = _("Updated!"); + $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); +} + +sub edit : Path : Args(1) { + my ( $self, $c, $id ) = @_; + + $c->forward('/auth/get_csrf_token'); + + my $user = $c->cobrand->users->find( { id => $id } ); + $c->detach( '/page_error_404_not_found', [] ) unless $user; + + unless ( $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) { + $c->detach('/page_error_403_access_denied', []); + } + + $c->stash->{user} = $user; + $c->forward( '/admin/check_username_for_abuse', [ $user ] ); + + if ( $user->from_body && $c->user->has_permission_to('user_manage_permissions', $user->from_body->id) ) { + $c->stash->{available_permissions} = $c->cobrand->available_permissions; + } + + $c->forward('/admin/fetch_all_bodies'); + $c->forward('/admin/fetch_body_areas', [ $user->from_body ]) if $user->from_body; + $c->cobrand->call_hook('admin_user_edit_extra_data'); + + if ( defined $c->flash->{status_message} ) { + $c->stash->{status_message} = + '<p><em>' . $c->flash->{status_message} . '</em></p>'; + } + + $c->forward('/auth/check_csrf_token') if $c->get_param('submit'); + + if ( $c->get_param('submit') and $c->get_param('unban') ) { + $c->forward('unban', [ $user ]); + } elsif ( $c->get_param('submit') and $c->get_param('logout_everywhere') ) { + $c->forward('user_logout_everywhere', [ $user ]); + } elsif ( $c->get_param('submit') and $c->get_param('anon_everywhere') ) { + $c->forward('user_anon_everywhere', [ $user ]); + } elsif ( $c->get_param('submit') and $c->get_param('hide_everywhere') ) { + $c->forward('user_hide_everywhere', [ $user ]); + } elsif ( $c->get_param('submit') and $c->get_param('remove_account') ) { + $c->forward('user_remove_account', [ $user ]); + } elsif ( $c->get_param('submit') and $c->get_param('send_login_email') ) { + my $email = lc $c->get_param('email'); + my %args = ( email => $email ); + $args{user_id} = $id if $user->email ne $email || !$user->email_verified; + $c->forward('send_login_email', [ \%args ]); + } elsif ( $c->get_param('update_alerts') ) { + $c->forward('update_alerts'); + } elsif ( $c->get_param('submit') ) { + + my $edited = 0; + + my $name = $c->get_param('name'); + my $email = lc $c->get_param('email'); + my $phone = $c->get_param('phone'); + my $email_v = $c->get_param('email_verified') || 0; + my $phone_v = $c->get_param('phone_verified') || 0; + + $c->stash->{field_errors} = {}; + + unless ($email || $phone) { + $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); + } + if (!$email_v && !$phone_v) { + $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); + } + if ($email && !is_valid_email($email)) { + $c->stash->{field_errors}->{email} = _('Please enter a valid email'); + } + + if ($phone_v) { + my $parsed_phone = $c->forward('phone_check', [ $phone ]); + $phone = $parsed_phone if $parsed_phone; + } + + unless ($name) { + $c->stash->{field_errors}->{name} = _('Please enter a name'); + } + + my $email_params = { email => $email, email_verified => 1, id => { '!=', $user->id } }; + my $phone_params = { phone => $phone, phone_verified => 1, id => { '!=', $user->id } }; + my $existing_email = $email_v && $c->model('DB::User')->search($email_params)->first; + my $existing_phone = $phone_v && $c->model('DB::User')->search($phone_params)->first; + my $existing_user = $existing_email || $existing_phone; + my $existing_email_cobrand = $email_v && $c->cobrand->users->search($email_params)->first; + my $existing_phone_cobrand = $phone_v && $c->cobrand->users->search($phone_params)->first; + my $existing_user_cobrand = $existing_email_cobrand || $existing_phone_cobrand; + if ($existing_phone_cobrand && $existing_email_cobrand && $existing_email_cobrand->id != $existing_phone_cobrand->id) { + $c->stash->{field_errors}->{username} = _('User already exists'); + } + + return if %{$c->stash->{field_errors}}; + + if ( ($user->email || "") ne $email || + $user->name ne $name || + ($user->phone || "") ne $phone || + ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) || + (!$user->from_body && $c->get_param('body')) + ) { + $edited = 1; + } + + if ($existing_user_cobrand) { + $existing_user->adopt($user); + $c->forward( '/admin/log_edit', [ $id, 'user', 'merge' ] ); + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $existing_user->id ) ); + } + + $user->email($email) if !$existing_email; + $user->phone($phone) if !$existing_phone; + $user->email_verified( $email_v ); + $user->phone_verified( $phone_v ); + $user->name( $name ); + + $user->flagged( $c->get_param('flagged') || 0 ); + # Only superusers can grant superuser status + $user->is_superuser( ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0 ); + # Superusers can set from_body to any value, but other staff can only + # set from_body to the same value as their own from_body. + if ( $c->user->is_superuser || $c->cobrand->moniker eq 'zurich' ) { + $user->from_body( $c->get_param('body') || undef ); + } elsif ( $c->user->has_body_permission_to('user_assign_body') ) { + if ($c->get_param('body') && $c->get_param('body') eq $c->user->from_body->id ) { + $user->from_body( $c->user->from_body ); + } else { + $user->from_body( undef ); + } + } + + $c->forward('user_cobrand_extra_fields'); + + # Has the user's from_body changed since we fetched areas (if we ever did)? + # If so, we need to re-fetch areas so the UI is up to date. + if ( $user->from_body && $user->from_body->id ne $c->stash->{fetched_areas_body_id} ) { + $c->forward('/admin/fetch_body_areas', [ $user->from_body ]); + } + + if (!$user->from_body) { + # Non-staff users aren't allowed any permissions or to be in an area + $user->admin_user_body_permissions->delete; + $user->area_ids(undef); + delete $c->stash->{areas}; + delete $c->stash->{fetched_areas_body_id}; + } elsif ($c->stash->{available_permissions}) { + my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} }; + my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions; + $user->admin_user_body_permissions->search({ + body_id => $user->from_body->id, + permission_type => { '!=' => \@user_permissions }, + })->delete; + foreach my $permission_type (@user_permissions) { + $user->user_body_permissions->find_or_create({ + body_id => $user->from_body->id, + permission_type => $permission_type, + }); + } + } + + if ( $user->from_body && $c->user->has_permission_to('user_assign_areas', $user->from_body->id) ) { + my %valid_areas = map { $_->{id} => 1 } @{ $c->stash->{areas} }; + my @area_ids = grep { $valid_areas{$_} } $c->get_param_list('area_ids'); + $user->area_ids( @area_ids ? \@area_ids : undef ); + } + + # Handle 'trusted' flag(s) + my @trusted_bodies = $c->get_param_list('trusted_bodies'); + if ( $c->user->is_superuser ) { + $user->user_body_permissions->search({ + body_id => { '!=' => \@trusted_bodies }, + permission_type => 'trusted', + })->delete; + foreach my $body_id (@trusted_bodies) { + $user->user_body_permissions->find_or_create({ + body_id => $body_id, + permission_type => 'trusted', + }); + } + } elsif ( $c->user->from_body ) { + my %trusted = map { $_ => 1 } @trusted_bodies; + my $body_id = $c->user->from_body->id; + if ( $trusted{$body_id} ) { + $user->user_body_permissions->find_or_create({ + body_id => $body_id, + permission_type => 'trusted', + }); + } else { + $user->user_body_permissions->search({ + body_id => $body_id, + permission_type => 'trusted', + })->delete; + } + } + + # Update the categories this user operates in + if ( $user->from_body ) { + $c->stash->{body} = $user->from_body; + $c->forward('/admin/fetch_contacts'); + my @live_contacts = $c->stash->{live_contacts}->all; + my @live_contact_ids = map { $_->id } @live_contacts; + my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; + $user->set_extra_metadata('categories', \@new_contact_ids); + } + + $user->update; + if ($edited) { + $c->forward( '/admin/log_edit', [ $id, 'user', 'edit' ] ); + } + $c->flash->{status_message} = _("Updated!"); + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); + } + + if ( $user->from_body ) { + unless ( $c->stash->{live_contacts} ) { + $c->stash->{body} = $user->from_body; + $c->forward('/admin/fetch_contacts'); + } + my @contacts = @{$user->get_extra_metadata('categories') || []}; + my %active_contacts = map { $_ => 1 } @contacts; + my @live_contacts = $c->stash->{live_contacts}->all; + my @all_contacts = map { { + id => $_->id, + category => $_->category, + active => $active_contacts{$_->id}, + } } @live_contacts; + $c->stash->{contacts} = \@all_contacts; + } + + # this goes after in case we've delete any alerts + unless ( $c->cobrand->moniker eq 'zurich' ) { + $c->forward('user_alert_details'); + } + + return 1; +} + +sub import :Local { + my ( $self, $c, $id ) = @_; + + $c->forward('/auth/get_csrf_token'); + return unless $c->user_exists && $c->user->is_superuser; + + return unless $c->req->method eq 'POST'; + + $c->forward('/auth/check_csrf_token'); + $c->stash->{new_users} = []; + $c->stash->{existing_users} = []; + + my @all_permissions = map { keys %$_ } values %{ $c->cobrand->available_permissions }; + my %available_permissions = map { $_ => 1 } @all_permissions; + + my $csv = Text::CSV->new({ binary => 1}); + my $fh = $c->req->upload('csvfile')->fh; + $csv->getline($fh); # discard the header + while (my $row = $csv->getline($fh)) { + my ($name, $email, $from_body, $permissions) = @$row; + $email = lc Utils::trim_text($email); + my @permissions = split(/:/, $permissions); + + my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 }); + if ($user->in_storage) { + push @{$c->stash->{existing_users}}, $user; + next; + } + + $user->name($name); + $user->from_body($from_body || undef); + $user->update_or_insert; + + my @user_permissions = grep { $available_permissions{$_} } @permissions; + foreach my $permission_type (@user_permissions) { + $user->user_body_permissions->find_or_create({ + body_id => $user->from_body->id, + permission_type => $permission_type, + }); + } + + push @{$c->stash->{new_users}}, $user; + } +} + +sub phone_check : Private { + my ($self, $c, $phone) = @_; + my $parsed = FixMyStreet::SMS->parse_username($phone); + if ($parsed->{phone} && $parsed->{may_be_mobile}) { + return $parsed->{username}; + } elsif ($parsed->{phone}) { + $c->stash->{field_errors}->{phone} = _('Please enter a mobile number'); + } else { + $c->stash->{field_errors}->{phone} = _('Please check your phone number is correct'); + } +} + +sub user_cobrand_extra_fields : Private { + my ( $self, $c ) = @_; + + my @extra_fields = @{ $c->cobrand->call_hook('user_extra_fields') || [] }; + foreach ( @extra_fields ) { + $c->stash->{user}->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); + } +} + +sub user_alert_details : Private { + my ( $self, $c ) = @_; + + my @alerts = $c->stash->{user}->alerts({}, { prefetch => 'alert_type' })->all; + $c->stash->{alerts} = \@alerts; + + my @wards; + + for my $alert (@alerts) { + if ($alert->alert_type->ref eq 'ward_problems') { + push @wards, $alert->parameter2; + } + } + + if (@wards) { + $c->stash->{alert_areas} = FixMyStreet::MapIt::call('areas', join(',', @wards) ); + } + + my %body_names = map { $_->{id} => $_->{name} } @{ $c->stash->{bodies} }; + $c->stash->{body_names} = \%body_names; +} + +sub update_alerts : Private { + my ($self, $c) = @_; + + my $changes; + for my $alert ( $c->stash->{user}->alerts ) { + my $edit_option = $c->get_param('edit_alert[' . $alert->id . ']'); + next unless $edit_option; + $changes = 1; + if ( $edit_option eq 'delete' ) { + $alert->delete; + } elsif ( $edit_option eq 'disable' ) { + $alert->disable; + } elsif ( $edit_option eq 'enable' ) { + $alert->confirm; + } + } + $c->flash->{status_message} = _("Updated!") if $changes; +} + +sub user_logout_everywhere : Private { + my ( $self, $c, $user ) = @_; + my $sessions = $user->get_extra_metadata('sessions'); + foreach (grep { $_ ne $c->sessionid } @$sessions) { + $c->delete_session_data("session:$_"); + } + $c->stash->{status_message} = _('That user has been logged out.'); +} + +sub user_anon_everywhere : Private { + my ( $self, $c, $user ) = @_; + $user->problems->update({anonymous => 1}); + $user->comments->update({anonymous => 1}); + $c->stash->{status_message} = _('That user has been made anonymous on all reports and updates.'); +} + +sub user_hide_everywhere : Private { + my ( $self, $c, $user ) = @_; + my $problems = $user->problems->search({ state => { '!=' => 'hidden' } }); + while (my $problem = $problems->next) { + $problem->get_photoset->delete_cached; + $problem->update({ state => 'hidden' }); + } + my $updates = $user->comments->search({ state => { '!=' => 'hidden' } }); + while (my $update = $updates->next) { + $update->hide; + } + $c->stash->{status_message} = _('That user’s reports and updates have been hidden.'); +} + +sub send_login_email : Private { + my ( $self, $c, $args ) = @_; + + my $token_data = { + email => $args->{email}, + }; + + $token_data->{old_user_id} = $args->{user_id} if $args->{user_id}; + $token_data->{name} = $args->{name} if $args->{name}; + + my $token_obj = $c->model('DB::Token')->create({ + scope => 'email_sign_in', + data => $token_data, + }); + + $c->stash->{token} = $token_obj->token; + my $template = 'login.txt'; + + # do not use relative URIs in the email, obvs. + $c->uri_disposition('absolute'); + $c->send_email( $template, { to => $args->{email} } ); + + $c->stash->{status_message} = _('The user has been sent a login email'); +} + +# Anonymize and remove name from all problems/updates, disable all alerts. +# Remove their account's email address, phone number, password, etc. +sub user_remove_account : Private { + my ( $self, $c, $user ) = @_; + $c->forward('user_logout_everywhere', [ $user ]); + $user->anonymize_account; + $c->stash->{status_message} = _('That user’s personal details have been removed.'); +} + +=head2 ban + +Add the user's email address/phone number to the abuse table if they are not +already in there and sets status_message accordingly. + +=cut + +sub ban : Private { + my ( $self, $c ) = @_; + + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } + return unless $user; + + if ($user->email_verified && $user->email) { + my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->email }); + if ( $abuse->in_storage ) { + $c->stash->{status_message} = _('User already in abuse list'); + } else { + $abuse->insert; + $c->stash->{status_message} = _('User added to abuse list'); + } + $c->stash->{username_in_abuse} = 1; + } + if ($user->phone_verified && $user->phone) { + my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->phone }); + if ( $abuse->in_storage ) { + $c->stash->{status_message} = _('User already in abuse list'); + } else { + $abuse->insert; + $c->stash->{status_message} = _('User added to abuse list'); + } + $c->stash->{username_in_abuse} = 1; + } + return 1; +} + +sub unban : Private { + my ( $self, $c, $user ) = @_; + + my @username; + if ($user->email_verified && $user->email) { + push @username, $user->email; + } + if ($user->phone_verified && $user->phone) { + push @username, $user->phone; + } + if (@username) { + my $abuse = $c->model('DB::Abuse')->search({ email => \@username }); + if ( $abuse ) { + $abuse->delete; + $c->stash->{status_message} = _('user removed from abuse list'); + } else { + $c->stash->{status_message} = _('user not in abuse list'); + } + $c->stash->{username_in_abuse} = 0; + } +} + +=head2 flag + +Sets the flag on a user + +=cut + +sub flag : Private { + my ( $self, $c ) = @_; + + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } + + if ( !$user ) { + $c->stash->{status_message} = _('Could not find user'); + } else { + $user->flagged(1); + $user->update; + $c->stash->{status_message} = _('User flagged'); + } + + $c->stash->{user_flagged} = 1; + + return 1; +} + +=head2 flag_remove + +Remove the flag on a user + +=cut + +sub flag_remove : Private { + my ( $self, $c ) = @_; + + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } + + if ( !$user ) { + $c->stash->{status_message} = _('Could not find user'); + } else { + $user->flagged(0); + $user->update; + $c->stash->{status_message} = _('User flag removed'); + } + + return 1; +} + + +sub trim { + my $self = shift; + my $e = shift; + $e =~ s/^\s+//; + $e =~ s/\s+$//; + return $e; +} + +=head1 AUTHOR + +mySociety + +=head1 LICENSE + +This library is free software. You can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index 8fed5c3aa..a09161494 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -9,6 +9,7 @@ use Encode; use JSON::MaybeXS; use Utils; use Try::Tiny; +use Text::CSV; =head1 NAME @@ -54,6 +55,9 @@ sub index : Path : Args(0) { || $c->forward('/location/determine_location_from_pc'); unless ($ret) { return $c->res->redirect('/') unless $c->get_param('pc') || $partial_report; + # Cobrand may want to perform custom searching at this point, + # e.g. presenting a list of reports matching the user's query. + $c->cobrand->call_hook("around_custom_search"); return; } @@ -227,6 +231,10 @@ sub check_and_stash_category : Private { my $all_areas = $c->stash->{all_areas}; my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all; my %bodies = map { $_->id => $_ } @bodies; + my @list_of_names = map { $_->name } values %bodies; + my $csv = Text::CSV->new(); + $csv->combine(@list_of_names); + $c->{stash}->{list_of_names_as_string} = $csv->string; my @categories = $c->model('DB::Contact')->not_deleted->search( { @@ -250,16 +258,18 @@ sub map_features : Private { my ($self, $c, $extra) = @_; $c->stash->{page} = 'around'; # Needed by _item.html / so the map knows to make clickable pins, update on pan + $c->stash->{num_old_reports} = 0; $c->forward( '/reports/stash_report_filter_status' ); $c->forward( '/reports/stash_report_sort', [ 'created-desc' ]); + $c->stash->{show_old_reports} = $c->get_param('show_old_reports'); return if $c->get_param('js'); # JS will request the same (or more) data client side # Allow the cobrand to add in any additional query parameters my $extra_params = $c->cobrand->call_hook('display_location_extra_params'); - my ( $on_map, $nearby, $distance ) = + my ( $on_map, $nearby ) = FixMyStreet::Map::map_features( $c, %$extra, categories => [ keys %{$c->stash->{filter_category}} ], @@ -280,7 +290,6 @@ sub map_features : Private { $c->stash->{pins} = \@pins; $c->stash->{on_map} = $on_map; $c->stash->{around_map} = $nearby; - $c->stash->{distance} = $distance; } =head2 ajax @@ -308,6 +317,18 @@ sub ajax : Path('/ajax') { $c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]); } +sub nearby : Path { + my ($self, $c) = @_; + + my $states = FixMyStreet::DB::Result::Problem->open_states(); + $c->forward('/report/_nearby_json', [ { + latitude => $c->get_param('latitude'), + longitude => $c->get_param('longitude'), + categories => [ $c->get_param('filter_category') || () ], + states => $states, + } ]); +} + sub location_closest_address : Path('/ajax/closest') { my ( $self, $c ) = @_; $c->res->content_type('application/json; charset=utf-8'); @@ -389,10 +410,13 @@ sub _geocode : Private { sub lookup_by_ref : Private { my ( $self, $c, $ref ) = @_; - my $problems = $c->cobrand->problems->search([ - id => $ref, - external_id => $ref - ]); + my $criteria = $c->cobrand->call_hook("lookup_by_ref", $ref) || + [ + id => $ref, + external_id => $ref + ]; + + my $problems = $c->cobrand->problems->search( $criteria ); my $count = try { $problems->count; diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 533e6a9be..c194045b9 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -54,9 +54,18 @@ sub general : Path : Args(0) { } -sub general_test : Path('_test_') : Args(0) { +sub create : Path('create') : Args(0) { my ( $self, $c ) = @_; - $c->stash->{template} = 'auth/token.html'; + return unless $c->req->method eq 'POST'; + $c->detach('code_sign_in'); +} + +sub forgot : Path('forgot') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{forgotten} = 1; + $c->stash->{template} = 'auth/create.html'; + return unless $c->req->method eq 'POST'; + $c->detach('code_sign_in'); } sub authenticate : Private { @@ -77,7 +86,6 @@ sub sign_in : Private { $username ||= ''; my $password = $c->get_param('password_sign_in') || ''; - my $remember_me = $c->get_param('remember_me') || 0; # Sign out just in case $c->logout(); @@ -91,10 +99,6 @@ sub sign_in : Private { $c->user->update({ password => $password }); } - # unless user asked to be remembered limit the session to browser - $c->set_session_cookie_expire(0) - unless $remember_me; - # Regenerate CSRF token as session ID changed $c->forward('get_csrf_token'); @@ -104,7 +108,6 @@ sub sign_in : Private { $c->stash( sign_in_error => 1, username => $username, - remember_me => $remember_me, ); return; } @@ -224,7 +227,8 @@ sub token : Path('/M') : Args(1) { my $data = $c->forward('get_token', [ $url_token, 'email_sign_in' ]) || return; $c->stash->{token_not_found} = 1, return - if $data->{old_user_id} && (!$c->user_exists || $c->user->id ne $data->{old_user_id}); + if $data->{old_user_id} && $data->{r} && $data->{r} eq 'auth/change_email/success' + && (!$c->user_exists || $c->user->id ne $data->{old_user_id}); my $type = $data->{login_type} || 'email'; $c->detach( '/auth/process_login', [ $data, $type ] ); @@ -314,7 +318,7 @@ categories this user has been assigned to. sub redirect_to_categories : Private { my ( $self, $c ) = @_; - my $categories = join(',', @{ $c->user->categories }); + my $categories = $c->user->categories_string; my $body_short = $c->cobrand->short_name( $c->user->from_body ); $c->res->redirect( $c->uri_for( "/reports/" . $body_short, { filter_category => $categories } ) ); diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm index 997009b87..fb525fc1f 100644 --- a/perllib/FixMyStreet/App/Controller/Contact.pm +++ b/perllib/FixMyStreet/App/Controller/Contact.pm @@ -4,6 +4,7 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } +use MIME::Base64; use mySociety::EmailUtil; use FixMyStreet::Email; @@ -17,8 +18,19 @@ Contact us page =head1 METHODS +=head2 auto + +Functions to run on both GET and POST contact requests. + =cut +sub auto : Private { + my ($self, $c) = @_; + $c->forward('setup_request'); + $c->forward('determine_contact_type'); + $c->forward('/auth/get_csrf_token'); +} + =head2 index Display contact us page @@ -27,10 +39,6 @@ Display contact us page sub index : Path : Args(0) { my ( $self, $c ) = @_; - - return - unless $c->forward('setup_request') - && $c->forward('determine_contact_type'); } =head2 submit @@ -42,20 +50,12 @@ Handle contact us form submission sub submit : Path('submit') : Args(0) { my ( $self, $c ) = @_; - if (my $testing = $c->get_param('_test_')) { - $c->stash->{success} = $c->get_param('success'); - return; - } - $c->res->redirect( '/contact' ) and return unless $c->req->method eq 'POST'; - return - unless $c->forward('setup_request') - && $c->forward('determine_contact_type') - && $c->forward('validate') - && $c->forward('prepare_params_for_email') - && $c->forward('send_email') - && $c->forward('redirect_on_success'); + $c->go('index') unless $c->forward('validate'); + $c->forward('prepare_params_for_email'); + $c->forward('send_email'); + $c->forward('redirect_on_success'); } =head2 determine_contact_type @@ -105,6 +105,8 @@ sub determine_contact_type : Private { if ( $c->get_param("reject") && $c->user->has_permission_to(report_reject => $c->stash->{problem}->bodies_str_ids) ) { $c->stash->{rejecting_report} = 1; } + } elsif ( $c->cobrand->abuse_reports_only ) { + $c->detach( '/page_error_404_not_found' ); } return 1; @@ -120,6 +122,10 @@ to index page if errors. sub validate : Private { my ( $self, $c ) = @_; + $c->forward('/auth/check_csrf_token'); + my $s = $c->stash->{s} = unpack("N", decode_base64($c->get_param('s'))); + return if !FixMyStreet->test_mode && time() < $s; # uncoverable statement + my ( %field_errors, @errors ); my %required = ( name => _('Please enter your name'), @@ -157,7 +163,7 @@ sub validate : Private { if ( @errors or scalar keys %field_errors ) { $c->stash->{errors} = \@errors; $c->stash->{field_errors} = \%field_errors; - $c->go('index'); + return 0; } return 1; @@ -233,6 +239,10 @@ sub setup_request : Private { # name is already used in the stash for the app class name $c->stash->{form_name} = $c->get_param('name'); + my $s = encode_base64(pack("N", time() + 10), ''); + $s =~ s/=+$//; + $c->stash->{s} = $s; + return 1; } @@ -262,6 +272,7 @@ sub send_email : Private { my $from = [ $c->stash->{em}, $c->stash->{form_name} ]; my $params = { to => [ [ $recipient, _($recipient_name) ] ], + user_agent => $c->req->user_agent, }; if (FixMyStreet::Email::test_dmarc($c->stash->{em})) { $params->{'Reply-To'} = [ $from ]; diff --git a/perllib/FixMyStreet/App/Controller/Council.pm b/perllib/FixMyStreet/App/Controller/Council.pm index 2e2dce0f7..4acaba903 100644 --- a/perllib/FixMyStreet/App/Controller/Council.pm +++ b/perllib/FixMyStreet/App/Controller/Council.pm @@ -2,6 +2,8 @@ package FixMyStreet::App::Controller::Council; use Moose; use namespace::autoclean; +use FixMyStreet::MapIt; + BEGIN {extends 'Catalyst::Controller'; } =head1 NAME @@ -59,10 +61,6 @@ sub load_and_check_areas : Private { my $all_areas; - my %params; - $params{generation} = $c->config->{MAPIT_GENERATION} - if $c->config->{MAPIT_GENERATION}; - if ($prefetched_all_areas) { $all_areas = { map { $_ => { id => $_ } } @@ -71,8 +69,7 @@ sub load_and_check_areas : Private { } elsif ( $c->stash->{fetch_all_areas} ) { my %area_types = map { $_ => 1 } @$area_types; $all_areas = - mySociety::MaPit::call( 'point', - "4326/$longitude,$latitude", %params ); + FixMyStreet::MapIt::call('point', "4326/$longitude,$latitude"); $c->stash->{all_areas_mapit} = $all_areas; $all_areas = { map { $_ => $all_areas->{$_} } @@ -81,9 +78,7 @@ sub load_and_check_areas : Private { }; } else { $all_areas = - mySociety::MaPit::call( 'point', - "4326/$longitude,$latitude", %params, - type => $area_types ); + FixMyStreet::MapIt::call('point', "4326/$longitude,$latitude", type => $area_types); } if ($all_areas->{error}) { $c->stash->{location_error_mapit_error} = 1; diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index 790e7ec29..bd60f8570 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -3,10 +3,12 @@ use Moose; use namespace::autoclean; use DateTime; +use Encode; use JSON::MaybeXS; use Path::Tiny; use Text::CSV; use Time::Piece; +use FixMyStreet::DateRange; BEGIN { extends 'Catalyst::Controller'; } @@ -54,6 +56,18 @@ Checks if we can view this page, and if not redirect to 404. sub check_page_allowed : Private { my ( $self, $c ) = @_; + # dashboard_permission can return undef (if not present, or to carry on + # with default behaviour), a body ID to use that body for results, or 0 + # to refuse access entirely + my $cobrand_check = $c->cobrand->call_hook('dashboard_permission'); + if (defined $cobrand_check) { + if ($cobrand_check) { + $cobrand_check = $c->model('DB::Body')->find({ id => $cobrand_check }); + } + $c->detach( '/page_error_404_not_found' ) if !$cobrand_check; + return $cobrand_check; + } + $c->detach( '/auth/redirect' ) unless $c->user_exists; $c->detach( '/page_error_404_not_found' ) @@ -93,13 +107,18 @@ sub index : Path : Args(0) { # See if we've had anything from the body dropdowns $c->stash->{category} = $c->get_param('category'); - $c->stash->{ward} = $c->get_param('ward'); - if ($c->user->area_id) { - $c->stash->{ward} = $c->user->area_id; - $c->stash->{body_name} = join "", map { $children->{$_}->{name} } grep { $children->{$_} } $c->user->area_id; + $c->stash->{ward} = [ $c->get_param_list('ward') ]; + if ($c->user_exists) { + if (my @areas = @{$c->user->area_ids || []}) { + $c->stash->{ward} = $c->user->area_ids; + $c->stash->{body_name} = join " / ", sort map { $children->{$_}->{name} } grep { $children->{$_} } @areas; + } } } else { - my @bodies = $c->model('DB::Body')->active->translated->with_area_count->all_sorted; + my @bodies = $c->model('DB::Body')->search(undef, { + columns => [ "id", "name" ], + })->active->translated->with_area_count->all_sorted; + $c->stash->{ward} = []; $c->stash->{bodies} = \@bodies; } @@ -110,10 +129,14 @@ 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->forward('construct_rs_filter', [ $c->get_param('updates') ]); if ( $c->get_param('export') ) { - $c->forward('export_as_csv'); + if ($c->get_param('updates')) { + $c->forward('export_as_csv_updates'); + } else { + $c->forward('export_as_csv'); + } } else { $c->forward('generate_grouped_data'); $self->generate_summary_figures($c); @@ -121,36 +144,39 @@ sub index : Path : Args(0) { } sub construct_rs_filter : Private { - my ($self, $c) = @_; + my ($self, $c, $updates) = @_; my %where; - $where{areas} = { 'like', '%,' . $c->stash->{ward} . ',%' } - if $c->stash->{ward}; + $where{areas} = [ map { { 'like', "%,$_,%" } } @{$c->stash->{ward}} ] + if @{$c->stash->{ward}}; $where{category} = $c->stash->{category} if $c->stash->{category}; + my $table_name = $updates ? 'problem' : 'me'; + my $state = $c->stash->{q_state}; if ( FixMyStreet::DB::Result::Problem->fixed_states->{$state} ) { # Probably fixed - council - $where{'me.state'} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; + $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; } elsif ( $state ) { - $where{'me.state'} = $state; + $where{"$table_name.state"} = $state; } else { - $where{'me.state'} = [ FixMyStreet::DB::Result::Problem->visible_states() ]; + $where{"$table_name.state"} = [ FixMyStreet::DB::Result::Problem->visible_states() ]; } - my $dtf = $c->model('DB')->storage->datetime_parser; - - my $start_date = $dtf->parse_datetime($c->stash->{start_date}); - $where{'me.confirmed'} = { '>=', $dtf->format_datetime($start_date) }; + my $days30 = DateTime->now(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(days => 30); + $days30->truncate( to => 'day' ); - if (my $end_date = $c->stash->{end_date}) { - my $one_day = DateTime::Duration->new( days => 1 ); - $end_date = $dtf->parse_datetime($end_date) + $one_day; - $where{'me.confirmed'} = [ -and => $where{'me.confirmed'}, { '<', $dtf->format_datetime($end_date) } ]; - } + my $range = FixMyStreet::DateRange->new( + start_date => $c->stash->{start_date}, + start_default => $days30, + end_date => $c->stash->{end_date}, + formatter => $c->model('DB')->storage->datetime_parser, + ); + $where{"$table_name.confirmed"} = $range->sql; $c->stash->{params} = \%where; - $c->stash->{problems_rs} = $c->cobrand->problems->to_body($c->stash->{body})->search( \%where ); + my $rs = $updates ? $c->cobrand->updates : $c->cobrand->problems; + $c->stash->{objects_rs} = $rs->to_body($c->stash->{body})->search( \%where ); } sub generate_grouped_data : Private { @@ -182,7 +208,7 @@ sub generate_grouped_data : Private { @groups = qw/category state/; %grouped = map { $_->category => {} } @{$c->stash->{contacts}}; } - my $problems = $c->stash->{problems_rs}->search(undef, { + my $problems = $c->stash->{objects_rs}->search(undef, { group_by => [ map { ref $_ ? $_->{-as} : $_ } @groups ], select => [ @groups, { count => 'me.id' } ], as => [ @groups == 2 ? qw/key1 key2 count/ : qw/key1 count/ ], @@ -238,7 +264,7 @@ sub generate_summary_figures { # problems this month by state $c->stash->{"summary_$_"} = 0 for values %$state_map; - $c->stash->{summary_open} = $c->stash->{problems_rs}->count; + $c->stash->{summary_open} = $c->stash->{objects_rs}->count; my $params = $c->stash->{params}; $params = { map { my $n = $_; s/me\./problem\./ unless /me\.confirmed/; $_ => $params->{$n} } keys %$params }; @@ -268,15 +294,54 @@ sub generate_summary_figures { sub generate_body_response_time : Private { my ( $self, $c ) = @_; - my $avg = $c->stash->{body}->calculate_average; + my $avg = $c->stash->{body}->calculate_average($c->cobrand->call_hook("body_responsiveness_threshold")); $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'], + }), + 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), + }; + $c->cobrand->call_hook("dashboard_export_updates_add_columns"); + $c->forward('generate_csv'); +} + sub export_as_csv : Private { my ($self, $c) = @_; my $csv = $c->stash->{csv} = { - problems => $c->stash->{problems_rs}->search_rs({}, { + objects => $c->stash->{objects_rs}->search_rs({}, { prefetch => 'comments', order_by => ['me.confirmed', 'me.id'], }), @@ -298,6 +363,8 @@ sub export_as_csv : Private { 'Easting', 'Northing', 'Report URL', + 'Site Used', + 'Reported As', ], columns => [ 'id', @@ -317,23 +384,12 @@ sub export_as_csv : Private { 'local_coords_x', 'local_coords_y', 'url', + 'site_used', + 'reported_as', ], - filename => do { - my %where = ( - category => $c->stash->{category}, - state => $c->stash->{q_state}, - ward => $c->stash->{ward}, - ); - $where{body} = $c->stash->{body}->id if $c->stash->{body}; - join '-', - $c->req->uri->host, - map { - my $value = $where{$_}; - (defined $value and length $value) ? ($_, $value) : () - } sort keys %where - }, + filename => $self->csv_filename($c, 0), }; - $c->cobrand->call_hook("dashboard_export_add_columns"); + $c->cobrand->call_hook("dashboard_export_problems_add_columns"); $c->forward('generate_csv'); } @@ -354,24 +410,44 @@ hashref of extra data to include that can be used by 'columns'. 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->combine(@{$c->stash->{csv}->{headers}}); - my @body = ($csv->string); + $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 $problems = $c->stash->{csv}->{problems}; - while ( my $report = $problems->next ) { - my $hashref = $report->as_hashref($c, \%asked_for); + my $objects = $c->stash->{csv}->{objects}; + while ( my $obj = $objects->next ) { + my $hashref = $obj->as_hashref($c, \%asked_for); - $hashref->{user_name_display} = $report->anonymous - ? '(anonymous)' : $report->name; + $hashref->{user_name_display} = $obj->anonymous + ? '(anonymous)' : $obj->name; if ($asked_for{acknowledged}) { - for my $comment ($report->comments) { + 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'; @@ -392,28 +468,33 @@ sub generate_csv : Private { split ',', $hashref->{areas}; } - ($hashref->{local_coords_x}, $hashref->{local_coords_y}) = - $report->local_coords; - $hashref->{url} = join '', $c->cobrand->base_url_for_report($report), $report->url; + if ($obj->can('local_coords') && $asked_for{local_coords_x}) { + ($hashref->{local_coords_x}, $hashref->{local_coords_y}) = + $obj->local_coords; + } + 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->($report); + my $extra = $fn->($obj); $hashref = { %$hashref, %$extra }; } - $csv->combine( + $csv->print($c->response, [ + map { + $_ = encode('UTF-8', $_) if $_; + } @{$hashref}{ @{$c->stash->{csv}->{columns}} }, - ); - - push @body, $csv->string; + ] ); } - - my $filename = $c->stash->{csv}->{filename}; - $c->res->content_type('text/csv; charset=utf-8'); - $c->res->header('content-disposition' => "attachment; filename=${filename}.csv"); - $c->res->body( join "", @body ); } =head1 AUTHOR diff --git a/perllib/FixMyStreet/App/Controller/Develop.pm b/perllib/FixMyStreet/App/Controller/Develop.pm index 0bc52883f..ae7122fa1 100755 --- a/perllib/FixMyStreet/App/Controller/Develop.pm +++ b/perllib/FixMyStreet/App/Controller/Develop.pm @@ -26,10 +26,21 @@ Makes sure this controller is only available when run in development. sub auto : Private { my ($self, $c) = @_; - $c->detach( '/page_error_404_not_found' ) unless $c->config->{STAGING_SITE}; + $c->detach( '/page_error_404_not_found' ) unless $c->user_exists && $c->user->is_superuser; return 1; } +=item index + +Shows a list of links to preview HTML emails. + +=cut + +sub index : Path('/_dev') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{problem} = $c->model('DB::Problem')->search(undef, { rows => 1 } )->first; +} + =item email_list Shows a list of links to preview HTML emails. @@ -49,10 +60,11 @@ sub email_list : Path('/_dev/email') : Args(0) { my %with_update = ('update-confirm' => 1, 'other-updated' => 1); my %with_problem = ('alert-update' => 1, 'other-reported' => 1, 'problem-confirm' => 1, 'problem-confirm-not-sending' => 1, + 'confirm_report_sent' => 1, 'problem-moderated' => 1, 'questionnaire' => 1, 'submit' => 1); - my $update = $c->model('DB::Comment')->first; - my $problem = $c->model('DB::Problem')->first; + my $update = $c->model('DB::Comment')->search(undef, { rows => 1 } )->first; + my $problem = $c->model('DB::Problem')->search(undef, { rows => 1 } )->first; $c->stash->{templates} = []; foreach (sort keys %templates) { @@ -130,6 +142,115 @@ sub email_previewer : Path('/_dev/email') : Args(1) { $c->response->body($html); } +=item problem_confirm_previewer + +Displays the confirmation page for a given problem. + +=back + +=cut + +sub problem_confirm_previewer : Path('/_dev/confirm_problem') : Args(1) { + my ( $self, $c, $id ) = @_; + + $c->log->info('Previewing confirmation page for problem ' . $id); + + my $problem = $c->model('DB::Problem')->find( { id => $id } ) + || $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ); + $c->stash->{report} = $problem; + + $c->log->info('Problem ' . $id . ' found: ' . $problem->title); + $c->stash->{template} = 'tokens/confirm_problem.html'; +} + +=item update_confirm_previewer + +Displays the confirmation page for an update on the given problem. + +=back + +=cut + +sub update_confirm_previewer : Path('/_dev/confirm_update') : Args(1) { + my ( $self, $c, $id ) = @_; + + my $problem = $c->model('DB::Problem')->find( { id => $id } ) + || $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ); + $c->stash->{problem} = $problem; + + $c->stash->{template} = 'tokens/confirm_update.html'; +} + +=item alert_confirm_previewer + +Displays the confirmation page for an alert, with the supplied +confirmation type (ie: subscribed, or unsubscribed). + +=back + +=cut + +sub alert_confirm_previewer : Path('/_dev/confirm_alert') : Args(1) { + my ( $self, $c, $confirm_type ) = @_; + $c->stash->{confirm_type} = $confirm_type; + $c->stash->{template} = 'tokens/confirm_alert.html'; +} + +=item contact_submit_previewer + +Displays the contact submission page, with success based on the +truthyness of the supplied argument. + +=back + +=cut + +sub contact_submit_previewer : Path('/_dev/contact_submit') : Args(1) { + my ( $self, $c, $success ) = @_; + $c->stash->{success} = $success; + $c->stash->{template} = 'contact/submit.html'; +} + +=item questionnaire_completed_previewer + +Displays the questionnaire completed page, with content based on +the supplied ?new_state and ?been_fixed query params. + +=back + +=cut + +sub questionnaire_completed_previewer : Path('/_dev/questionnaire_completed') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{been_fixed} = $c->get_param('been_fixed'); + $c->stash->{new_state} = $c->get_param('new_state'); + $c->stash->{template} = 'questionnaire/completed.html'; +} + +=item questionnaire_creator_fixed_previewer + +Displays the page a user sees after they mark their own report as fixed. + +=back + +=cut + +sub questionnaire_creator_fixed_previewer : Path('/_dev/questionnaire_creator_fixed') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{template} = 'questionnaire/creator_fixed.html'; +} + +sub auth_preview : Path('/_dev/auth') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{template} = 'auth/token.html'; +} + +sub report_new_preview : Path('/_dev/report_new') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{template} = 'email_sent.html'; + $c->stash->{email_type} = $c->get_param('email_type'); +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/JS.pm b/perllib/FixMyStreet/App/Controller/JS.pm index 1ced9d43b..f430ce672 100755 --- a/perllib/FixMyStreet/App/Controller/JS.pm +++ b/perllib/FixMyStreet/App/Controller/JS.pm @@ -20,7 +20,9 @@ of translation strings. sub translation_strings : LocalRegex('^translation_strings\.(.*?)\.js$') : Args(0) { my ( $self, $c ) = @_; my $lang = $c->req->captures->[0]; - $c->cobrand->set_lang_and_domain( $lang, 1 ); + $c->cobrand->set_lang_and_domain( $lang, 1, + FixMyStreet->path_to('locale')->stringify + ); $c->res->content_type( 'application/javascript' ); } diff --git a/perllib/FixMyStreet/App/Controller/JSON.pm b/perllib/FixMyStreet/App/Controller/JSON.pm index 762e3c115..ccc5b31dc 100644 --- a/perllib/FixMyStreet/App/Controller/JSON.pm +++ b/perllib/FixMyStreet/App/Controller/JSON.pm @@ -8,6 +8,7 @@ use JSON::MaybeXS; use DateTime; use DateTime::Format::ISO8601; use List::MoreUtils 'uniq'; +use FixMyStreet::DateRange; =head1 NAME @@ -50,16 +51,19 @@ sub problems : Local { } # convert the dates to datetimes and trap errors - my $iso8601 = DateTime::Format::ISO8601->new; - my $start_dt = eval { $iso8601->parse_datetime($start_date); }; - my $end_dt = eval { $iso8601->parse_datetime($end_date); }; - unless ( $start_dt && $end_dt ) { + my $range = FixMyStreet::DateRange->new( + start_date => $start_date, + end_date => $end_date, + parser => DateTime::Format::ISO8601->new, + formatter => $c->model('DB')->schema->storage->datetime_parser, + ); + unless ($range->start && $range->end) { $c->stash->{error} = 'Invalid dates supplied'; return; } # check that the dates are sane - if ( $start_dt > $end_dt ) { + if ($range->start >= $range->end) { $c->stash->{error} = 'Start date after end date'; return; } @@ -80,15 +84,10 @@ sub problems : Local { $date_col = 'lastupdate'; } - my $dt_parser = $c->model('DB')->schema->storage->datetime_parser; - - my $one_day = DateTime::Duration->new( days => 1 ); my $query = { - $date_col => { - '>=' => $dt_parser->format_datetime($start_dt), - '<=' => $dt_parser->format_datetime($end_dt + $one_day), - }, + $date_col => $range->sql, state => [ @state ], + non_public => 0, }; $query->{category} = $category if $category; my @problems = $c->cobrand->problems->search( $query, { diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index 86143b5ea..22869d531 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -23,9 +23,9 @@ data to change. - user to be from_body - user to have a "moderate" record in user_body_permissions -The original data of the report is stored in moderation_original_data, so -that it can be reverted/consulted if required. All moderation events are -stored in admin_log. +The original and previous data of the report is stored in +moderation_original_data, so that it can be reverted/consulted if required. +All moderation events are stored in admin_log. =head1 SEE ALSO @@ -37,71 +37,155 @@ DB tables: =cut +sub end : ActionClass('RenderView') { + my ($self, $c) = @_; + + if ($c->stash->{moderate_errors}) { + $c->stash->{show_moderation} = 'report'; + $c->stash->{template} = 'report/display.html'; + $c->forward('/report/display'); + } elsif ($c->res->redirect) { + # Do nothing if we're already going somewhere + } else { + $c->res->redirect($c->stash->{report_uri}); + } +} + sub moderate : Chained('/') : PathPart('moderate') : CaptureArgs(0) { } sub report : Chained('moderate') : PathPart('report') : CaptureArgs(1) { my ($self, $c, $id) = @_; my $problem = $c->model('DB::Problem')->find($id); + $c->detach unless $problem; my $cobrand_base = $c->cobrand->base_url_for_report( $problem ); my $report_uri = $cobrand_base . $problem->url; $c->stash->{cobrand_base} = $cobrand_base; $c->stash->{report_uri} = $report_uri; - $c->res->redirect( $report_uri ); # this will be the final endpoint after all processing... - # ... and immediately, if the user isn't authorized $c->detach unless $c->user_exists; - $c->detach unless $c->user->has_permission_to(moderate => $problem->bodies_str_ids); $c->forward('/auth/check_csrf_token'); - my $original = $problem->find_or_new_related( moderation_original_data => { + $c->stash->{history} = $problem->new_related( moderation_original_data => { title => $problem->title, detail => $problem->detail, photo => $problem->photo, anonymous => $problem->anonymous, + longitude => $problem->longitude, + latitude => $problem->latitude, + category => $problem->category, + $problem->extra ? (extra => $problem->extra) : (), }); + $c->stash->{original} = $problem->moderation_original_data || $c->stash->{history}; $c->stash->{problem} = $problem; - $c->stash->{problem_original} = $original; $c->stash->{moderation_reason} = $c->get_param('moderation_reason') // ''; } sub moderate_report : Chained('report') : PathPart('') : Args(0) { my ($self, $c) = @_; + my $problem = $c->stash->{problem}; + + # Make sure user can moderate this report + $c->detach unless $c->user->can_moderate($problem); + + $c->forward('check_edited_elsewhere'); $c->forward('report_moderate_hide'); my @types = grep $_, - $c->forward('report_moderate_title'), - $c->forward('report_moderate_detail'), - $c->forward('report_moderate_anon'), - $c->forward('report_moderate_photo'); + $c->forward('moderate_state'), + ($c->user->can_moderate_title($problem, 1) + ? $c->forward('moderate_text', [ 'title' ]) + : ()), + $c->forward('moderate_text', [ 'detail' ]), + $c->forward('moderate_boolean', [ 'anonymous', 'show_name' ]), + $c->forward('moderate_boolean', [ 'photo' ]), + $c->forward('moderate_location'), + $c->forward('moderate_category'), + $c->forward('moderate_extra'); + + # Deal with possible photo changes. If a moderate form uses a standard + # photo upload field (with upload_fileid, label and file upload handlers), + # this will allow photos to be changed, not just switched on/off. You will + # probably want a hidden field with problem_photo=1 to skip that check. + my $photo_edit_form = defined $c->get_param('photo1'); + if ($photo_edit_form) { + $c->forward('/photo/process_photo'); + if ( my $photo_error = delete $c->stash->{photo_error} ) { + $c->stash->{moderate_errors} ||= []; + push @{ $c->stash->{moderate_errors} }, $photo_error; + } else { + my $fileid = $c->stash->{upload_fileid}; + if ($fileid ne $problem->photo) { + $problem->get_photoset->delete_cached; + $problem->photo($fileid || undef); + push @types, 'photo'; + } + } + } - $c->detach( 'report_moderate_audit', \@types ) + $c->detach( 'report_moderate_audit', \@types ); } -sub moderating_user_name { - my $user = shift; - return $user->from_body ? $user->from_body->name : _('an administrator'); +sub check_edited_elsewhere : Private { + my ($self, $c) = @_; + + my $problem = $c->stash->{problem}; + my $last_moderation = $problem->latest_moderation; + return unless $last_moderation; + + my $form_started = $c->get_param('form_started') || 0; + if ($form_started && $form_started < $last_moderation->created->epoch) { + $c->stash->{moderate_errors} ||= []; + push @{$c->stash->{moderate_errors}}, + _('Someone has moderated this report since you started.') . ' ' . + sprintf(_('Please <a href="#%s">check their changes</a> and resolve any differences.'), + 'update_m' . $last_moderation->id); + $c->detach; + } } -sub report_moderate_audit : Private { - my ($self, $c, @types) = @_; +sub moderate_log_entry : Private { + my ($self, $c, $object_type, @types) = @_; my $user = $c->user->obj; my $reason = $c->stash->{'moderation_reason'}; - my $problem = $c->stash->{problem} or die; + my $object = $object_type eq 'update' ? $c->stash->{comment} : $c->stash->{problem}; my $types_csv = join ', ' => @types; + my $log_reason = "($types_csv)"; + $log_reason = "$reason $log_reason" if $reason; + + # We attach the log to the moderation entry if present, or the object if not (hiding) $c->model('DB::AdminLog')->create({ action => 'moderation', user => $user, - admin_user => moderating_user_name($user), - object_id => $problem->id, - object_type => 'problem', - reason => (sprintf '%s (%s)', $reason, $types_csv), + admin_user => $user->moderating_user_name, + object_id => $c->stash->{history}->id || $object->id, + object_type => $c->stash->{history}->id ? 'moderation' : $object_type, + reason => $log_reason, }); +} + +sub report_moderate_audit : Private { + my ($self, $c, @types) = @_; + + my $problem = $c->stash->{problem} or die; + + return unless @types; # If nothing moderated, nothing to do + return if $c->stash->{moderate_errors}; # Don't update anything if errors + + # Okay, now update the report + $problem->update; + + return if @types == 1 && $types[0] eq 'state'; # If only state changed, no log entry needed + + # We've done some non-state moderation, save the history + $c->stash->{history}->insert; + + $c->forward('moderate_log_entry', [ 'problem', @types ]); if ($problem->user->email_verified && $c->cobrand->send_moderation_notifications) { my $token = $c->model("DB::Token")->create({ @@ -109,6 +193,7 @@ sub report_moderate_audit : Private { data => { id => $problem->id } }); + my $types_csv = join ', ' => @types; $c->send_email( 'problem-moderated.txt', { to => [ [ $problem->user->email, $problem->name ] ], types => $types_csv, @@ -116,6 +201,7 @@ sub report_moderate_audit : Private { problem => $problem, report_uri => $c->stash->{report_uri}, report_complain_uri => $c->stash->{cobrand_base} . '/contact?m=' . $token->token, + moderated_data => $c->stash->{history}, }); } } @@ -135,97 +221,153 @@ sub report_moderate_hide : Private { } } -sub report_moderate_title : Private { - my ( $self, $c ) = @_; +sub moderate_text : Private { + my ($self, $c, $thing) = @_; - my $problem = $c->stash->{problem} or die; - my $original = $c->stash->{problem_original}; + my $object = $c->stash->{comment} || $c->stash->{problem}; + my $param = $c->stash->{comment} ? 'update_' : 'problem_'; - my $old_title = $problem->title; - my $original_title = $original->title; + my $thing_for_original_table = $thing; + # Update 'text' field is stored in original table's 'detail' field + $thing_for_original_table = 'detail' if $c->stash->{comment} && $thing eq 'text'; - my $title = $c->get_param('problem_revert_title') ? - $original_title - : $c->get_param('problem_title'); + my $old = $object->$thing; + my $original_thing = $c->stash->{original}->$thing_for_original_table; - if ($title ne $old_title) { - $original->insert unless $original->in_storage; - $problem->update({ title => $title }); - return 'title'; - } + my $new = $c->get_param($param . 'revert_' . $thing) ? + $original_thing + : $c->get_param($param . $thing); - return; + if ($new ne $old) { + $object->$thing($new); + return $thing_for_original_table; + } } -sub report_moderate_detail : Private { - my ( $self, $c ) = @_; +sub moderate_boolean : Private { + my ( $self, $c, $thing, $reverse ) = @_; - my $problem = $c->stash->{problem} or die; - my $original = $c->stash->{problem_original}; - - my $old_detail = $problem->detail; - my $original_detail = $original->detail; - my $detail = $c->get_param('problem_revert_detail') ? - $original_detail - : $c->get_param('problem_detail'); - - if ($detail ne $old_detail) { - $original->insert unless $original->in_storage; - $problem->update({ detail => $detail }); - return 'detail'; + my $object = $c->stash->{comment} || $c->stash->{problem}; + my $param = $c->stash->{comment} ? 'update_' : 'problem_'; + my $original = $c->stash->{original}->photo; + + return if $thing eq 'photo' && !$original; + + my $new; + if ($reverse) { + $new = $c->get_param($param . $reverse) ? 0 : 1; + } else { + $new = $c->get_param($param . $thing) ? 1 : 0; + } + my $old = $object->$thing ? 1 : 0; + + if ($new != $old) { + if ($thing eq 'photo') { + $object->$thing($new ? $original : undef); + $object->get_photoset->delete_cached; + } else { + $object->$thing($new); + } + return $thing; } - return; } -sub report_moderate_anon : Private { - my ( $self, $c ) = @_; +sub moderate_extra : Private { + my ($self, $c) = @_; - my $problem = $c->stash->{problem} or die; - my $original = $c->stash->{problem_original}; + my $object = $c->stash->{comment} || $c->stash->{problem}; + + my $changed; + my @extra = grep { /^extra\./ } keys %{$c->req->params}; + foreach (@extra) { + my ($field_name) = /extra\.(.*)/; + my $old = $object->get_extra_metadata($field_name) || ''; + my $new = $c->get_param($_); + if ($new ne $old) { + $object->set_extra_metadata($field_name, $new); + $changed = 1; + } + } + if ($changed) { + return 'extra'; + } +} - my $show_user = $c->get_param('problem_show_name') ? 1 : 0; - my $anonymous = $show_user ? 0 : 1; - my $old_anonymous = $problem->anonymous ? 1 : 0; +sub moderate_location : Private { + my ($self, $c) = @_; - if ($anonymous != $old_anonymous) { + my $problem = $c->stash->{problem}; - $original->insert unless $original->in_storage; - $problem->update({ anonymous => $anonymous }); - return 'anonymous'; + my $moved = $c->forward('/admin/report_edit_location', [ $problem ]); + if (!$moved) { + # New lat/lon isn't valid, show an error + $c->stash->{moderate_errors} ||= []; + push @{ $c->stash->{moderate_errors} }, _('Invalid location. New location must be covered by the same council.'); + } elsif ($moved == 2) { + return 'location'; } - return; } -sub report_moderate_photo : Private { - my ( $self, $c ) = @_; +# No update left at present +sub moderate_category : Private { + my ($self, $c) = @_; - my $problem = $c->stash->{problem} or die; - my $original = $c->stash->{problem_original}; + return unless $c->get_param('category'); - return unless $original->photo; + # The admin category editing needs to know all the categories etc + $c->forward('/admin/categories_for_point'); - my $show_photo = $c->get_param('problem_show_photo') ? 1 : 0; - my $old_show_photo = $problem->photo ? 1 : 0; + my $problem = $c->stash->{problem}; - if ($show_photo != $old_show_photo) { - $original->insert unless $original->in_storage; - $problem->update({ photo => $show_photo ? $original->photo : undef }); - return 'photo'; + my $changed = $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + # It might need to set_report_extras in future + if ($changed) { + return 'category'; + } +} + +# Note that if a cobrand allows state moderation, then the moderation reason +# given will be added as an update and thus be publicly available (unlike with +# normal moderation). +sub moderate_state : Private { + my ($self, $c) = @_; + + my $new_state = $c->get_param('state'); + return unless $new_state; + + my $problem = $c->stash->{problem}; + if ($problem->state ne $new_state) { + $problem->state($new_state); + $problem->add_to_comments( { + text => $c->stash->{moderation_reason}, + created => \'current_timestamp', + confirmed => \'current_timestamp', + user_id => $c->user->id, + name => $c->user->from_body ? $c->user->from_body->name : $c->user->name, + state => 'confirmed', + mark_fixed => 0, + anonymous => $c->user->from_body ? 0 : 1, + problem_state => $new_state, + } ); + return 'state'; } - return; } sub update : Chained('report') : PathPart('update') : CaptureArgs(1) { my ($self, $c, $id) = @_; my $comment = $c->stash->{problem}->comments->find($id); - my $original = $comment->find_or_new_related( moderation_original_data => { + # Make sure user can moderate this update + $c->detach unless $comment && $c->user->can_moderate($comment); + + $c->stash->{history} = $comment->new_related( moderation_original_data => { detail => $comment->text, photo => $comment->photo, anonymous => $comment->anonymous, + $comment->extra ? (extra => $comment->extra) : (), }); $c->stash->{comment} = $comment; - $c->stash->{comment_original} = $original; + $c->stash->{original} = $comment->moderation_original_data || $c->stash->{history}; } sub moderate_update : Chained('update') : PathPart('') : Args(0) { @@ -234,31 +376,16 @@ sub moderate_update : Chained('update') : PathPart('') : Args(0) { $c->forward('update_moderate_hide'); my @types = grep $_, - $c->forward('update_moderate_detail'), - $c->forward('update_moderate_anon'), - $c->forward('update_moderate_photo'); - - $c->detach( 'update_moderate_audit', \@types ) -} - -sub update_moderate_audit : Private { - my ($self, $c, @types) = @_; - - my $user = $c->user->obj; - my $reason = $c->stash->{'moderation_reason'}; - my $problem = $c->stash->{problem} or die; - my $comment = $c->stash->{comment} or die; - - my $types_csv = join ', ' => @types; - - $c->model('DB::AdminLog')->create({ - action => 'moderation', - user => $user, - admin_user => moderating_user_name($user), - object_id => $comment->id, - object_type => 'update', - reason => (sprintf '%s (%s)', $reason, $types_csv), - }); + $c->forward('moderate_text', [ 'text' ]), + $c->forward('moderate_boolean', [ 'anonymous', 'show_name' ]), + $c->forward('moderate_extra'), + $c->forward('moderate_boolean', [ 'photo' ]); + + if (@types) { + $c->stash->{history}->insert; + $c->stash->{comment}->update; + $c->detach('moderate_log_entry', [ 'update', @types ]); + } } sub update_moderate_hide : Private { @@ -269,77 +396,10 @@ sub update_moderate_hide : Private { if ($c->get_param('update_hide')) { $comment->hide; - $c->detach( 'update_moderate_audit', ['hide'] ); # break chain here. - } - return; -} - -sub update_moderate_detail : Private { - my ( $self, $c ) = @_; - - my $problem = $c->stash->{problem} or die; - my $comment = $c->stash->{comment} or die; - my $original = $c->stash->{comment_original}; - - my $old_detail = $comment->text; - my $original_detail = $original->detail; - my $detail = $c->get_param('update_revert_detail') ? - $original_detail - : $c->get_param('update_detail'); - - if ($detail ne $old_detail) { - $original->insert unless $original->in_storage; - $comment->update({ text => $detail }); - return 'detail'; - } - return; -} - -sub update_moderate_anon : Private { - my ( $self, $c ) = @_; - - my $problem = $c->stash->{problem} or die; - my $comment = $c->stash->{comment} or die; - my $original = $c->stash->{comment_original}; - - my $show_user = $c->get_param('update_show_name') ? 1 : 0; - my $anonymous = $show_user ? 0 : 1; - my $old_anonymous = $comment->anonymous ? 1 : 0; - - if ($anonymous != $old_anonymous) { - $original->insert unless $original->in_storage; - $comment->update({ anonymous => $anonymous }); - return 'anonymous'; - } - return; -} - -sub update_moderate_photo : Private { - my ( $self, $c ) = @_; - - my $problem = $c->stash->{problem} or die; - my $comment = $c->stash->{comment} or die; - my $original = $c->stash->{comment_original}; - - return unless $original->photo; - - my $show_photo = $c->get_param('update_show_photo') ? 1 : 0; - my $old_show_photo = $comment->photo ? 1 : 0; - - if ($show_photo != $old_show_photo) { - $original->insert unless $original->in_storage; - $comment->update({ photo => $show_photo ? $original->photo : undef }); - return 'photo'; + $c->detach('moderate_log_entry', [ 'update', 'hide' ]); # break chain here. } } -sub return_text : Private { - my ($self, $c, $text) = @_; - - $c->res->content_type('text/plain; charset=utf-8'); - $c->res->body( $text // '' ); -} - __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm index 883ccc0ce..ed890ad82 100644 --- a/perllib/FixMyStreet/App/Controller/My.pm +++ b/perllib/FixMyStreet/App/Controller/My.pm @@ -97,6 +97,8 @@ sub planned_reorder : Private { sub get_problems : Private { my ($self, $c) = @_; + $c->stash->{page} = 'my'; + my $p_page = $c->get_param('p') || 1; $c->forward( '/reports/stash_report_filter_status' ); @@ -159,13 +161,12 @@ sub setup_page_data : Private { my @categories = $c->stash->{problems_rs}->search({ state => [ FixMyStreet::DB::Result::Problem->visible_states() ], }, { - columns => [ 'category', 'extra' ], + columns => [ 'category' ], distinct => 1, order_by => [ 'category' ], } )->all; $c->stash->{filter_categories} = \@categories; - $c->stash->{page} = 'my'; my $pins = $c->stash->{pins}; FixMyStreet::Map::display_map( $c, @@ -209,7 +210,7 @@ sub planned_change : Path('planned/change') { $c->res->content_type('application/json; charset=utf-8'); $c->res->body(encode_json({ outcome => $add ? 'add' : 'remove' })); } else { - $c->res->redirect( $c->uri_for_action('report/display', $id) ); + $c->res->redirect( $c->uri_for_action('report/display', [ $id ]) ); } } diff --git a/perllib/FixMyStreet/App/Controller/Open311.pm b/perllib/FixMyStreet/App/Controller/Open311.pm index 83b9b8202..841330e92 100644 --- a/perllib/FixMyStreet/App/Controller/Open311.pm +++ b/perllib/FixMyStreet/App/Controller/Open311.pm @@ -7,6 +7,7 @@ use namespace::autoclean; use JSON::MaybeXS; use XML::Simple; use DateTime::Format::W3CDTF; +use FixMyStreet::MapIt; BEGIN { extends 'Catalyst::Controller'; } @@ -164,9 +165,7 @@ sub get_services : Private { if ($lat || $lon) { my $area_types = $c->cobrand->area_types; - my $all_areas = mySociety::MaPit::call('point', - "4326/$lon,$lat", - type => $area_types); + my $all_areas = FixMyStreet::MapIt::call('point', "4326/$lon,$lat", type => $area_types); $categories = $categories->search( { 'body_areas.area_id' => [ keys %$all_areas ], }, { join => { 'body' => 'body_areas' } } ); @@ -310,7 +309,8 @@ sub get_requests : Private { delete $states->{unconfirmed}; delete $states->{submitted}; my $criteria = { - state => [ keys %$states ] + state => [ keys %$states ], + non_public => 0, }; my %rules = ( @@ -415,6 +415,7 @@ sub get_request : Private { my $criteria = { state => [ keys %$states ], id => $id, + non_public => 0, }; $c->forward( 'output_requests', [ $criteria ] ); } diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm index f41702dcf..7b536a292 100644 --- a/perllib/FixMyStreet/App/Controller/Photo.pm +++ b/perllib/FixMyStreet/App/Controller/Photo.pm @@ -5,8 +5,8 @@ use namespace::autoclean; BEGIN {extends 'Catalyst::Controller'; } use JSON::MaybeXS; -use File::Path; -use File::Slurp; +use Path::Tiny; +use Try::Tiny; use FixMyStreet::App::Model::PhotoSet; =head1 NAME @@ -46,6 +46,9 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg| my ( $self, $c ) = @_; my ( $is_update, $id, $photo_number, $size ) = @{ $c->req->captures }; + $photo_number ||= 0; + $size ||= ''; + my $item; if ( $is_update ) { ($item) = $c->model('DB::Comment')->search( { @@ -77,8 +80,10 @@ sub output : Private { my ( $self, $c, $photo ) = @_; # Save to file - File::Path::make_path( FixMyStreet->path_to( 'web', 'photo', 'c' )->stringify ); - File::Slurp::write_file( FixMyStreet->path_to( 'web', $c->req->path )->stringify, \$photo->{data} ); + path(FixMyStreet->path_to('web', 'photo', 'c'))->mkpath; + my $out = FixMyStreet->path_to('web', $c->req->path); + my $symlink_exists = $photo->{symlink} ? symlink($photo->{symlink}, $out) : undef; + path($out)->spew_raw($photo->{data}) unless $symlink_exists; $c->res->content_type( $photo->{content_type} ); $c->res->body( $photo->{data} ); @@ -101,8 +106,13 @@ sub upload : Local { c => $c, data_items => \@items, }); - - my $fileid = $photoset->data; + my $fileid = try { + $photoset->data; + } catch { + $c->log->debug("Photo upload failed."); + $c->stash->{photo_error} = _("Photo upload failed."); + return undef; + }; my $out; if ($c->stash->{photo_error} || !$fileid) { $c->res->status(500); diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm index 58848f546..d2b0bf3f4 100755 --- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm +++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm @@ -33,7 +33,8 @@ sub check_questionnaire : Private { my $problem = $questionnaire->problem; - if ( $unanswered && $questionnaire->whenanswered ) { + my $cutoff = DateTime->now()->subtract( minutes => 2 ); + if ( $unanswered && $questionnaire->whenanswered && $questionnaire->whenanswered < $cutoff) { my $problem_url = $c->cobrand->base_url_for_report( $problem ) . $problem->url; my $contact_url = $c->uri_for( "/contact" ); my $message = sprintf(_("You have already answered this questionnaire. If you have a question, please <a href='%s'>get in touch</a>, or <a href='%s'>view your problem</a>.\n"), $contact_url, $problem_url); @@ -64,15 +65,8 @@ sub submit : Path('submit') { my ( $self, $c ) = @_; if (my $token = $c->get_param('token')) { - if ($token eq '_test_') { - $c->stash->{been_fixed} = $c->get_param('been_fixed'); - $c->stash->{new_state} = $c->get_param('new_state'); - $c->stash->{template} = 'questionnaire/completed.html'; - return; - } $c->forward('submit_standard'); } elsif (my $p = $c->get_param('problem')) { - $c->detach('creator_fixed') if $p eq '_test_'; $c->forward('submit_creator_fixed'); } else { $c->detach( '/page_error_404_not_found' ); diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 799985f8e..7f798f4f4 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -20,8 +20,8 @@ Show a report =head2 index -Redirect to homepage unless C<id> parameter in query, in which case redirect to -'/report/$id'. +Redirect to homepage unless we have a homepage template, +in which case show that. =cut @@ -35,13 +35,13 @@ sub index : Path('') : Args(0) { } } -=head2 report_display +=head2 id -Display a report. +Load in ID, for use by chained pages. =cut -sub display : Path('') : Args(1) { +sub id :PathPart('report') :Chained :CaptureArgs(1) { my ( $self, $c, $id ) = @_; if ( @@ -49,15 +49,17 @@ sub display : Path('') : Args(1) { || $id =~ m{ ^(\d+) \D .* $ }x # trailing garbage ) { - return $c->res->redirect( $c->uri_for($1), 301 ); + $c->res->redirect( $c->uri_for($1), 301 ); + $c->detach; } - $c->forward( '_display', [ $id ] ); + $c->forward( 'load_problem_or_display_error', [ $id ] ); } =head2 ajax -Return JSON formatted details of a report +Return JSON formatted details of a report. +URL used by mobile app so remains /report/ajax/N. =cut @@ -65,40 +67,62 @@ sub ajax : Path('ajax') : Args(1) { my ( $self, $c, $id ) = @_; $c->stash->{ajax} = 1; - $c->forward( '_display', [ $id ] ); + $c->forward('load_problem_or_display_error', [ $id ]); + $c->forward('display'); } -sub _display : Private { - my ( $self, $c, $id ) = @_; +=head2 display + +Display a report. + +=cut + +sub display :PathPart('') :Chained('id') :Args(0) { + my ( $self, $c ) = @_; $c->forward('/auth/get_csrf_token'); - $c->forward( 'load_problem_or_display_error', [ $id ] ); $c->forward( 'load_updates' ); $c->forward( 'format_problem_for_display' ); my $permissions = $c->stash->{_permissions} ||= $c->forward( 'check_has_permission_to', - [ qw/report_inspect report_edit_category report_edit_priority/ ] ); + [ qw/report_inspect report_edit_category report_edit_priority report_mark_private/ ] ); if (any { $_ } values %$permissions) { $c->stash->{template} = 'report/inspect.html'; $c->forward('inspect'); } } -sub support : Path('support') : Args(0) { +sub moderate_report :PathPart('moderate') :Chained('id') :Args(0) { my ( $self, $c ) = @_; - my $id = $c->get_param('id'); + if ($c->user_exists && $c->user->can_moderate($c->stash->{problem})) { + $c->stash->{show_moderation} = 'report'; + $c->stash->{template} = 'report/display.html'; + $c->detach('display'); + } + $c->res->redirect($c->stash->{problem}->url); +} + +sub moderate_update :PathPart('moderate') :Chained('id') :Args(1) { + my ( $self, $c, $update_id ) = @_; - my $uri = - $id - ? $c->uri_for( '/report', $id ) - : $c->uri_for('/'); + my $comment = $c->stash->{problem}->comments->find($update_id); + if ($c->user_exists && $comment && $c->user->can_moderate($comment)) { + $c->stash->{show_moderation} = $update_id; + $c->stash->{template} = 'report/display.html'; + $c->detach('display'); + } + $c->res->redirect($c->stash->{problem}->url); +} - if ( $id && $c->cobrand->can_support_problems && $c->user && $c->user->from_body ) { - $c->forward( 'load_problem_or_display_error', [ $id ] ); +sub support :Chained('id') :Args(0) { + my ( $self, $c ) = @_; + + if ( $c->cobrand->can_support_problems && $c->user && $c->user->from_body ) { $c->stash->{problem}->update( { interest_count => \'interest_count +1' } ); } - $c->res->redirect( $uri ); + + $c->res->redirect($c->stash->{problem}->url); } sub load_problem_or_display_error : Private { @@ -130,8 +154,8 @@ sub load_problem_or_display_error : Private { # Creator, and inspection users can see non_public reports $c->stash->{problem} = $problem; my $permissions = $c->stash->{_permissions} = $c->forward( 'check_has_permission_to', - [ qw/report_inspect report_edit_category report_edit_priority/ ] ); - if ( !$c->user || ($c->user->id != $problem->user->id && !$permissions->{report_inspect}) ) { + [ qw/report_inspect report_edit_category report_edit_priority report_mark_private / ] ); + if ( !$c->user || ($c->user->id != $problem->user->id && !($permissions->{report_inspect} || $permissions->{report_mark_private})) ) { $c->detach( '/page_error_403_access_denied', [ sprintf(_('That report cannot be viewed on %s.'), $c->stash->{site_name}) ] @@ -140,7 +164,7 @@ sub load_problem_or_display_error : Private { } $c->stash->{problem} = $problem; - if ( $c->user_exists && $c->user->has_permission_to(moderate => $problem->bodies_str_ids) ) { + if ( $c->user_exists && $c->user->can_moderate($problem) ) { $c->stash->{problem_original} = $problem->find_or_new_related( moderation_original_data => { title => $problem->title, @@ -162,14 +186,30 @@ sub load_updates : Private { { order_by => [ 'confirmed', 'id' ] } ); - my $questionnaires = $c->model('DB::Questionnaire')->search( + my $questionnaires_still_open = $c->model('DB::Questionnaire')->search( + { + problem_id => $c->stash->{problem}->id, + whenanswered => { '!=', undef }, + -or => [ { + # Any steady state open/closed + old_state => [ -and => + { -in => [ FixMyStreet::DB::Result::Problem::closed_states, FixMyStreet::DB::Result::Problem::open_states ] }, + \'= new_state', + ], + }, { + # Any reopening + new_state => 'confirmed', + } ] + }, + { order_by => 'whenanswered' } + ); + + my $questionnaires_fixed = $c->model('DB::Questionnaire')->search( { problem_id => $c->stash->{problem}->id, whenanswered => { '!=', undef }, - old_state => [ -and => - { -in => [ FixMyStreet::DB::Result::Problem::closed_states, FixMyStreet::DB::Result::Problem::open_states ] }, - \'= new_state', - ] + old_state => { -not_in => [ FixMyStreet::DB::Result::Problem::fixed_states ] }, + new_state => { -in => [ FixMyStreet::DB::Result::Problem::fixed_states ] }, }, { order_by => 'whenanswered' } ); @@ -182,13 +222,36 @@ sub load_updates : Private { $questionnaires_with_updates{$qid} = $update; } } - while (my $q = $questionnaires->next) { + while (my $q = $questionnaires_still_open->next) { if (my $update = $questionnaires_with_updates{$q->id}) { $update->set_extra_metadata('open_from_questionnaire', 1); next; } push @combined, [ $q->whenanswered, $q ]; } + while (my $q = $questionnaires_fixed->next) { + next if $questionnaires_with_updates{$q->id}; + push @combined, [ $q->whenanswered, $q ]; + } + + # And include moderation changes... + my $problem = $c->stash->{problem}; + my $public_history = $c->cobrand->call_hook(public_moderation_history => $problem); + my $user_can_moderate = $c->user_exists && $c->user->can_moderate($problem); + if ($public_history || $user_can_moderate) { + my @history = $problem->moderation_history; + my $last_history = $problem; + foreach my $history (@history) { + push @combined, [ $history->created, { + id => 'm' . $history->id, + type => 'moderation', + last => $last_history, + entry => $history, + } ]; + $last_history = $history; + } + } + @combined = map { $_->[1] } sort { $a->[0] <=> $b->[0] } @combined; $c->stash->{updates} = \@combined; @@ -206,6 +269,9 @@ sub format_problem_for_display : Private { my $problem = $c->stash->{problem}; + # upload_fileid is used by the update form on this page + $c->stash->{problem_upload_fileid} = $problem->get_photoset->data; + ( $c->stash->{latitude}, $c->stash->{longitude} ) = map { Utils::truncate_coordinate($_) } ( $problem->latitude, $problem->longitude ); @@ -251,7 +317,7 @@ sub generate_map_tags : Private { latitude => $problem->latitude, longitude => $problem->longitude, pins => $problem->used_map - ? [ $problem->pin_data($c, 'report', type => 'big') ] + ? [ $problem->pin_data($c, 'report', type => 'big', draggable => 1) ] : [], ); @@ -271,22 +337,18 @@ users too about this change, at which point we can delete: =cut -sub delete :Local :Args(1) { - my ( $self, $c, $id ) = @_; +sub delete :Chained('id') :Args(0) { + my ($self, $c) = @_; $c->forward('/auth/check_csrf_token'); - $c->forward( 'load_problem_or_display_error', [ $id ] ); my $p = $c->stash->{problem}; - my $uri = $c->uri_for( '/report', $id ); - - return $c->res->redirect($uri) unless $c->user_exists; + return $c->res->redirect($p->url) unless $c->user_exists; my $body = $c->user->obj->from_body; - return $c->res->redirect($uri) unless $body; - - return $c->res->redirect($uri) unless $p->bodies->{$body->id}; + return $c->res->redirect($p->url) unless $body; + return $c->res->redirect($p->url) unless $p->bodies->{$body->id}; $p->state('hidden'); $p->lastupdate( \'current_timestamp' ); @@ -299,26 +361,10 @@ sub delete :Local :Args(1) { admin_user => $c->user->from_body->name, object_type => 'problem', action => 'state_change', - object_id => $id, + object_id => $p->id, } ); - return $c->res->redirect($uri); -} - -=head2 action_router - -A router for dispatching handlers for sub-actions on a particular report, -e.g. /report/1/inspect - -=cut - -sub action_router : Path('') : Args(2) { - my ( $self, $c, $id, $action ) = @_; - - $c->go( 'map', [ $id ] ) if $action eq 'map'; - $c->go( 'nearby_json', [ $id ] ) if $action eq 'nearby.json'; - - $c->detach( '/page_error_404_not_found', [] ); + return $c->res->redirect($p->url); } sub inspect : Private { @@ -327,7 +373,7 @@ sub inspect : Private { my $permissions = $c->stash->{_permissions}; $c->forward('/admin/categories_for_point'); - $c->stash->{report_meta} = { map { $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } }; + $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } }; if ($c->cobrand->can('council_area_id')) { my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}}); @@ -357,8 +403,6 @@ sub inspect : Private { my %update_params = (); if ($permissions->{report_inspect}) { - $problem->non_public($c->get_param('non_public') ? 1 : 0); - $problem->set_extra_metadata( traffic_information => $c->get_param('traffic_information') ); if ( my $info = $c->get_param('detailed_information') ) { @@ -375,12 +419,6 @@ sub inspect : Private { } } - if ( $c->get_param('defect_type') ) { - $problem->defect_type($problem->defect_types->find($c->get_param('defect_type'))); - } else { - $problem->defect_type(undef); - } - if ( $c->get_param('include_update') ) { $update_text = Utils::cleanup_text( $c->get_param('public_update'), { allow_multiline => 1 } ); if (!$update_text) { @@ -438,6 +476,8 @@ sub inspect : Private { } } + $problem->non_public($c->get_param('non_public') ? 1 : 0); + if ( !$c->forward( '/admin/report_edit_location', [ $problem ] ) ) { # New lat/lon isn't valid, show an error $valid = 0; @@ -461,11 +501,26 @@ sub inspect : Private { $c->forward('/report/new/set_report_extras', [ \@contacts, $param_prefix ]); } - # Updating priority must come after category, in case category has changed (and so might have priorities) - if ($c->get_param('priority') && ($permissions->{report_inspect} || $permissions->{report_edit_priority})) { - $problem->response_priority( $problem->response_priorities->find({ id => $c->get_param('priority') }) ); + # Updating priority/defect type must come after category, in case + # category has changed (and so might have priorities/defect types) + if ($permissions->{report_inspect} || $permissions->{report_edit_priority}) { + if ($c->get_param('priority')) { + $problem->response_priority( $problem->response_priorities->find({ id => $c->get_param('priority') }) ); + } else { + $problem->response_priority(undef); + } } + if ($permissions->{report_inspect}) { + if ( $c->get_param('defect_type') ) { + $problem->defect_type($problem->defect_types->find($c->get_param('defect_type'))); + } else { + $problem->defect_type(undef); + } + } + + $c->cobrand->call_hook(report_inspect_update_extra => $problem); + if ($valid) { if ( $reputation_change != 0 ) { $problem->user->update_reputation($reputation_change); @@ -479,7 +534,7 @@ sub inspect : Private { # to have the FMS timezone so we need to add the timezone otherwise # dates come back out the database at time +/- timezone offset. $timestamp = DateTime->from_epoch( - time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone, + time_zone => FixMyStreet->local_time_zone, epoch => $saved_at ); } @@ -508,7 +563,7 @@ sub inspect : Private { # shortlist is always a single click away, being on the main nav. if ($c->user->has_body_permission_to('planned_reports')) { unless ($redirect_uri = $c->get_param("post_inspect_url")) { - my $categories = join(',', @{ $c->user->categories }); + my $categories = $c->user->categories_string; my $params = { lat => $problem->latitude, lon => $problem->longitude, @@ -532,10 +587,8 @@ sub inspect : Private { } }; -sub map : Private { - my ( $self, $c, $id ) = @_; - - $c->forward( 'load_problem_or_display_error', [ $id ] ); +sub map :Chained('id') :Args(0) { + my ($self, $c) = @_; my $image = $c->stash->{problem}->static_map; $c->res->content_type($image->{content_type}); @@ -543,27 +596,44 @@ sub map : Private { } -sub nearby_json : Private { - my ( $self, $c, $id ) = @_; +sub nearby_json :PathPart('nearby.json') :Chained('id') :Args(0) { + my ($self, $c) = @_; - $c->forward( 'load_problem_or_display_error', [ $id ] ); my $p = $c->stash->{problem}; - my $dist = 1; + $c->forward('_nearby_json', [ { + latitude => $p->latitude, + longitude => $p->longitude, + categories => [ $p->category ], + ids => [ $p->id ], + } ]); +} + +sub _nearby_json :Private { + my ($self, $c, $params) = @_; # This is for the list template, this is a list on that page. $c->stash->{page} = 'report'; - my $extra_params = $c->cobrand->call_hook('display_location_extra_params'); + # distance in metres + my $dist = $c->get_param('distance') || ''; + $dist = 1000 unless $dist =~ /^\d+$/; + $dist = 1000 if $dist > 1000; + $params->{distance} = $dist / 1000; + + my $pin_size = $c->get_param('pin_size') || ''; + $pin_size = 'small' unless $pin_size =~ /^(mini|small|normal|big)$/; + + $params->{extra} = $c->cobrand->call_hook('display_location_extra_params'); + $params->{limit} = 5; + + my $nearby = $c->model('DB::Nearby')->nearby($c, %$params); - my $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, [ $p->id ], 5, $p->latitude, $p->longitude, [ $p->category ], undef, $extra_params - ); # Want to treat these as if they were on map $nearby = [ map { $_->problem } @$nearby ]; my @pins = map { my $p = $_->pin_data($c, 'around'); [ $p->{latitude}, $p->{longitude}, $p->{colour}, - $p->{id}, $p->{title}, 'small', JSON->false + $p->{id}, $p->{title}, $pin_size, JSON->false ] } @$nearby; diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index b5e5c5738..8944a9307 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -6,13 +6,14 @@ BEGIN { extends 'Catalyst::Controller'; } use Encode; use List::MoreUtils qw(uniq); +use List::Util 'first'; use POSIX 'strcoll'; use HTML::Entities; -use mySociety::MaPit; use Path::Class; use Utils; use mySociety::EmailUtil; use JSON::MaybeXS; +use Text::CSV; use FixMyStreet::SMS; =head1 NAME @@ -95,7 +96,7 @@ sub report_new : Path : Args(0) { # work out the location for this report and do some checks # Also show map if we're just updating the filters return $c->forward('redirect_to_around') - if !$c->forward('determine_location') || $c->get_param('filter_update'); + if !$c->forward('determine_location') || $c->get_param('pc_override') || $c->get_param('filter_update'); # create a problem from the submitted details $c->stash->{template} = "report/new/fill_in_details.html"; @@ -117,12 +118,6 @@ sub report_new : Path : Args(0) { $c->forward('redirect_or_confirm_creation'); } -sub report_new_test : Path('_test_') : Args(0) { - my ( $self, $c ) = @_; - $c->stash->{template} = 'email_sent.html'; - $c->stash->{email_type} = $c->get_param('email_type'); -} - # This is for the new phonegap versions of the app. It looks a lot like # report_new but there's a few workflow differences as we only ever want # to sent JSON back here @@ -187,10 +182,8 @@ sub report_form_ajax : Path('ajax') : Args(0) { # work out the location for this report and do some checks if ( ! $c->forward('determine_location') ) { - my $body = encode_json({ error => $c->stash->{location_error} }); - $c->res->content_type('application/json; charset=utf-8'); - $c->res->body($body); - return; + $c->stash->{json_response} = { error => $c->stash->{location_error} }; + $c->detach('send_json_response'); } $c->forward('setup_categories_and_bodies'); @@ -207,6 +200,7 @@ sub report_form_ajax : Path('ajax') : Args(0) { my $extra_titles_list = $c->cobrand->title_list($c->stash->{all_areas}); + my @list_of_names = map { $_->name } values %{$c->stash->{bodies}}; my $contribute_as = {}; if ($c->user_exists) { my @bodies = keys %{$c->stash->{bodies}}; @@ -218,20 +212,27 @@ sub report_form_ajax : Path('ajax') : Args(0) { $contribute_as->{body} = $ca_body if $ca_body; } - my $body = encode_json( - { - councils_text => $councils_text, - councils_text_private => $councils_text_private, - category => $category, - extra_name_info => $extra_name_info, - titles_list => $extra_titles_list, - %$contribute_as ? (contribute_as => $contribute_as) : (), - $top_message ? (top_message => $top_message) : (), - } - ); + my %by_category; + foreach my $contact (@{$c->stash->{category_options}}) { + next if ref $contact eq 'HASH'; # Ignore the 'Pick a category' line + my $cat = $c->stash->{category} = $contact->category; + my $body = $c->forward('by_category_ajax_data', [ 'all', $cat ]); + $by_category{$cat} = $body; + } - $c->res->content_type('application/json; charset=utf-8'); - $c->res->body($body); + $c->stash->{json_response} = { + bodies => \@list_of_names, + councils_text => $councils_text, + councils_text_private => $councils_text_private, + category => $category, + extra_name_info => $extra_name_info, + titles_list => $extra_titles_list, + %$contribute_as ? (contribute_as => $contribute_as) : (), + $top_message ? (top_message => $top_message) : (), + unresponsive => $c->stash->{unresponsive}->{ALL} || '', + by_category => \%by_category, + }; + $c->detach('send_json_response'); } sub category_extras_ajax : Path('category_extras') : Args(0) { @@ -239,53 +240,60 @@ sub category_extras_ajax : Path('category_extras') : Args(0) { $c->forward('initialize_report'); if ( ! $c->forward('determine_location') ) { - my $body = encode_json({ error => _("Sorry, we could not find that location.") }); - $c->res->content_type('application/json; charset=utf-8'); - $c->res->body($body); - return 1; + $c->stash->{json_response} = { error => _("Sorry, we could not find that location.") }; + $c->detach('send_json_response'); } $c->forward('setup_categories_and_bodies'); $c->forward('setup_report_extra_fields'); - $c->forward('check_for_category'); + $c->forward('check_for_category'); my $category = $c->stash->{category} || ""; $category = '' if $category eq _('-- Pick a category --'); - my $bodies = $c->forward('contacts_to_bodies', [ $category ]); - my $vars = { - $category ? (list_of_names => [ map { $_->name } @$bodies ]) : (), - }; + $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $category ]); + $c->forward('send_json_response'); +} + +sub by_category_ajax_data : Private { + my ($self, $c, $type, $category) = @_; - my $category_extra = ''; - my $category_extra_json = []; my $generate; - if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) { - $c->stash->{category_extras} = { $category => $c->stash->{category_extras}->{$category} }; - $generate = 1; - } - if ($c->stash->{unresponsive}->{$category}) { - $generate = 1; - } - if ($c->stash->{report_extra_fields}) { + if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or + $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) { $generate = 1; } + + my $bodies = $c->forward('contacts_to_bodies', [ $category ]); + my $list_of_names = [ map { $_->name } ($category ? @$bodies : values %{$c->stash->{bodies_to_list}}) ]; + my $vars = { + $category ? (list_of_names => $list_of_names) : (), + }; + + my $body = { + bodies => $list_of_names, + }; + if ($generate) { - $category_extra = $c->render_fragment('report/new/category_extras.html', $vars); - $category_extra_json = $c->forward('generate_category_extra_json'); + $body->{category_extra} = $c->render_fragment('report/new/category_extras.html', $vars); + $body->{category_extra_json} = $c->forward('generate_category_extra_json'); + } - my $councils_text = $c->render_fragment( 'report/new/councils_text.html', $vars); - my $councils_text_private = $c->render_fragment( 'report/new/councils_text_private.html'); + my $unresponsive = $c->stash->{unresponsive}->{$category}; + $unresponsive ||= $c->stash->{unresponsive}->{ALL} || '' if $type eq 'one'; - my $body = encode_json({ - category_extra => $category_extra, - councils_text => $councils_text, - councils_text_private => $councils_text_private, - category_extra_json => $category_extra_json, - }); + # unresponsive must return empty string if okay, as that's what mobile app checks + if ($type eq 'one' || ($type eq 'all' && $unresponsive)) { + $body->{unresponsive} = $unresponsive; + # Check for no bodies here, because if there are any (say one + # unresponsive, one not), can use default display code for that. + if ($type eq 'all' && !@$bodies) { + $body->{councils_text} = $c->render_fragment( 'report/new/councils_text.html', $vars); + $body->{councils_text_private} = $c->render_fragment( 'report/new/councils_text_private.html'); + } + } - $c->res->content_type('application/json; charset=utf-8'); - $c->res->body($body); + return $body; } =head2 report_import @@ -412,6 +420,12 @@ sub report_import : Path('/import') { $c->send_email( 'partial.txt', { to => $report->user->email, } ); + if ( $c->get_param('web') ) { + $c->res->content_type('text/html; charset=utf-8'); + $c->stash->{template} = 'email_sent.html'; + $c->stash->{email_type} = 'problem'; + return 1; + } $c->res->body('SUCCESS'); return 1; } @@ -469,6 +483,9 @@ sub initialize_report : Private { # save the token to delete at the end $c->stash->{partial_token} = $token if $report; + $c->stash->{email} = $report->user->email; + $c->stash->{phone} = $report->user->phone_display; + } else { # no point keeping it if it is done. $token->delete; @@ -619,13 +636,12 @@ sub setup_categories_and_bodies : Private { my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all; my %bodies = map { $_->id => $_ } @bodies; - my $first_body = ( values %bodies )[0]; my $contacts # = $c # ->model('DB::Contact') # ->active - ->search( { body_id => [ keys %bodies ] }, { prefetch => 'body' } ); + ->search( { 'me.body_id' => [ keys %bodies ] }, { prefetch => 'body' } ); my @contacts = $c->cobrand->categories_restriction($contacts)->all; # variables to populate @@ -638,17 +654,18 @@ sub setup_categories_and_bodies : Private { (); # categories for which the reports are not public $c->stash->{unresponsive} = {}; - if (keys %bodies == 1 && $first_body->send_method && $first_body->send_method eq 'Refused') { - # If there's only one body, and it's set to refused, we can show the + my @refused_bodies = grep { ($_->send_method || "") eq 'Refused' } values %bodies; + if (@refused_bodies && @refused_bodies == values %bodies) { + # If all bodies are set to Refused, we can show the # message immediately, before they select a category. + my $k = 'ALL'; if ($c->action->name eq 'category_extras_ajax' && $c->req->method eq 'POST') { # The mobile app doesn't currently use this, in which case make # sure the message is output, either below with a category, or when # a blank category call is made. - $c->stash->{unresponsive}{""} = $first_body->id; - } else { - $c->stash->{unresponsive}{ALL} = $first_body->id; + $k = ""; } + $c->stash->{unresponsive}{$k} = { map { $_ => 1 } keys %bodies }; } # keysort does not appear to obey locale so use strcoll (see i18n.t) @@ -665,20 +682,25 @@ sub setup_categories_and_bodies : Private { $bodies_to_list{ $contact->body_id } = $contact->body; - unless ( $seen{$contact->category} ) { - push @category_options, $contact; + my $metas = $contact->get_metadata_for_input; + if (@$metas) { + push @{$category_extras{$contact->category}}, @$metas; + my $all_hidden = (grep { !$c->cobrand->category_extra_hidden($_) } @$metas) ? 0 : 1; + if (exists($category_extras_hidden{$contact->category})) { + $category_extras_hidden{$contact->category} &&= $all_hidden; + } else { + $category_extras_hidden{$contact->category} = $all_hidden; + } + } - my $metas = $contact->get_metadata_for_input; - $category_extras{$contact->category} = $metas if @$metas; - $category_extras_hidden{$contact->category} = (grep { !$c->cobrand->category_extra_hidden($_) } @$metas) ? 0 : 1; + $non_public_categories{ $contact->category } = 1 if $contact->non_public; - my $body_send_method = $bodies{$contact->body_id}->send_method || ''; - $c->stash->{unresponsive}{$contact->category} = $contact->body_id - if !$c->stash->{unresponsive}{ALL} && - ($contact->email =~ /^REFUSED$/i || $body_send_method eq 'Refused'); + my $body_send_method = $contact->body->send_method || ''; + $c->stash->{unresponsive}{$contact->category}{$contact->body_id} = 1 + if !$c->stash->{unresponsive}{ALL} && + ($contact->email =~ /^REFUSED$/i || $body_send_method eq 'Refused'); - $non_public_categories{ $contact->category } = 1 if $contact->non_public; - } + push @category_options, $contact unless $seen{$contact->category}; $seen{$contact->category} = $contact; } @@ -702,6 +724,12 @@ sub setup_categories_and_bodies : Private { $c->stash->{non_public_categories} = \%non_public_categories; $c->stash->{extra_name_info} = $first_area->{id} == COUNCIL_ID_BROMLEY ? 1 : 0; + # escape these so we can then split on , cleanly in the template. + my @list_of_names = map { $_->name } values %bodies_to_list; + my $csv = Text::CSV->new(); + $csv->combine(@list_of_names); + $c->stash->{list_of_names_as_string} = $csv->string; + my @missing_details_bodies = grep { !$bodies_to_list{$_->id} } values %bodies; my @missing_details_body_names = map { $_->name } @missing_details_bodies; @@ -729,7 +757,7 @@ sub setup_report_extra_fields : Private { return unless $c->cobrand->allow_report_extra_fields; - my @extras = $c->model('DB::ReportExtraFields')->for_cobrand($c->cobrand)->for_language($c->stash->{lang_code})->all; + my @extras = $c->model('DB::ReportExtraField')->for_cobrand($c->cobrand)->for_language($c->stash->{lang_code})->all; $c->stash->{report_extra_fields} = \@extras; } @@ -761,7 +789,10 @@ sub process_user : Private { # Extract all the params to a hash to make them easier to work with my %params = map { $_ => $c->get_param($_) } - ( 'username', 'email', 'name', 'phone', 'password_register', 'fms_extra_title' ); + ( 'email', 'name', 'phone', 'password_register', 'fms_extra_title' ); + + # Report form includes two username fields: #form_username_register and #form_username_sign_in + $params{username} = (first { $_ } $c->get_param_list('username')) || ''; if ( $c->cobrand->allow_anonymous_reports ) { my $anon_details = $c->cobrand->anonymous_account; @@ -782,9 +813,13 @@ sub process_user : Private { $report->user( $user ); $c->forward('update_user', [ \%params ]); + $c->stash->{phone} = $report->user->phone_display; + $c->stash->{email} = $report->user->email; + if ($c->stash->{contributing_as_body} or $c->stash->{contributing_as_anonymous_user}) { - $report->name($user->from_body->name); - $user->name($user->from_body->name) unless $user->name; + my $name = $user->moderating_user_name; + $report->name($name); + $user->name($name) unless $user->name; $c->stash->{no_reporter_alert} = 1; } @@ -805,6 +840,12 @@ sub process_user : Private { $c->stash->{phone_may_be_mobile} = $type eq 'phone' && $parsed->{may_be_mobile}; + $c->forward('update_user', [ \%params ]); + + $c->stash->{phone} = Utils::trim_text( $type eq 'phone' ? $report->user->phone_display : $params{phone} ); + $c->stash->{email} = Utils::trim_text( $type eq 'email' ? $report->user->email : $params{email} ); + + # The user is trying to sign in. We only care about username from the params. if ( $c->get_param('submit_sign_in') || $c->get_param('password_sign_in') ) { $c->stash->{tfa_data} = { @@ -825,7 +866,6 @@ sub process_user : Private { return 1; } - $c->forward('update_user', [ \%params ]); if ($params{password_register}) { $c->forward('/auth/test_password', [ $params{password_register} ]); $report->user->password($params{password_register}); @@ -872,7 +912,6 @@ sub process_report : Private { 'partial', # 'service', # 'non_public', - 'single_body_only' ); # load the report @@ -931,8 +970,20 @@ sub process_report : Private { return 1; } - my $bodies = $c->forward('contacts_to_bodies', [ $report->category, $params{single_body_only} ]); - my $body_string = join(',', map { $_->id } @$bodies) || '-1'; + # check that we've not indicated we only want to sent to a single body + # and if we find a matching one then only send to that. e.g. if we clicked + # on a TfL road on the map. + my $body_string = do { + if (my $single_body_only = $c->get_param('single_body_only')) { + my $body = $c->model('DB::Body')->search({ name => $single_body_only })->first; + $body ? $body->id : '-1'; + } else { + my $contact_options = {}; + $contact_options->{do_not_send} = [ $c->get_param_list('do_not_send', 1) ]; + my $bodies = $c->forward('contacts_to_bodies', [ $report->category, $contact_options ]); + join(',', map { $_->id } @$bodies) || '-1'; + } + }; $report->bodies_str($body_string); # Record any body IDs which might have meant to match, but had no contact @@ -988,59 +1039,46 @@ sub process_report : Private { } sub contacts_to_bodies : Private { - my ($self, $c, $category, $single_body_only) = @_; + my ($self, $c, $category, $options) = @_; my @contacts = grep { $_->category eq $category } @{$c->stash->{contacts}}; - # check that we've not indicated we only want to sent to a single body - # and if we find a matching one then only send to that. e.g. if we clicked - # on a TfL road on the map. - if ($single_body_only) { - my @contacts_filtered = grep { $_->body->name eq $single_body_only } @contacts; + # check that the front end has not indicated that we should not send to a + # body. This is usually because the asset code thinks it's not near enough + # to a road. + if ($options->{do_not_send}) { + my %do_not_send_check = map { $_ => 1 } @{$options->{do_not_send}}; + my @contacts_filtered = grep { !$do_not_send_check{$_->body->name} } @contacts; @contacts = @contacts_filtered if scalar @contacts_filtered; } - if ($c->stash->{unresponsive}{$category} || $c->stash->{unresponsive}{ALL} || !@contacts) { - []; - } else { + my $unresponsive = $c->stash->{unresponsive}{$category} || $c->stash->{unresponsive}{ALL}; + if ($unresponsive) { + @contacts = grep { !$unresponsive->{$_->body_id} } @contacts; + } elsif (@contacts) { if ( $c->cobrand->call_hook('singleton_bodies_str') ) { # Cobrands like Zurich can only ever have a single body: 'x', because some functionality # relies on string comparison against bodies_str. - [ $contacts[0]->body ]; - } else { - [ map { $_->body } @contacts ]; + @contacts = ($contacts[0]); } } + [ map { $_->body } @contacts ]; } sub set_report_extras : Private { my ($self, $c, $contacts, $param_prefix) = @_; $param_prefix ||= ""; - my @extra; - foreach my $contact (@$contacts) { - my $metas = $contact->get_metadata_for_input; - foreach my $field ( @$metas ) { - if ( lc( $field->{required} ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) { - unless ( $c->get_param($param_prefix . $field->{code}) ) { - $c->stash->{field_errors}->{ $field->{code} } = _('This information is required'); - } - } - push @extra, { - name => $field->{code}, - description => $field->{description}, - value => $c->get_param($param_prefix . $field->{code}) || '', - }; - } - } + my @metalist = map { [ $_->get_metadata_for_input, $param_prefix ] } @$contacts; + push @metalist, map { [ $_->get_extra_fields, "extra[" . $_->id . "]" ] } @{$c->stash->{report_extra_fields}}; - foreach my $extra_fields (@{ $c->stash->{report_extra_fields} }) { - my $metas = $extra_fields->get_extra_fields; - $param_prefix = "extra[" . $extra_fields->id . "]"; + my @extra; + foreach my $item (@metalist) { + my ($metas, $param_prefix) = @$item; foreach my $field ( @$metas ) { if ( lc( $field->{required} ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) { unless ( $c->get_param($param_prefix . $field->{code}) ) { - $c->stash->{field_errors}->{ $field->{code} } = _('This information is required'); + $c->stash->{field_errors}->{ 'x' . $field->{code} } = _('This information is required'); } } push @extra, { @@ -1055,7 +1093,7 @@ sub set_report_extras : Private { if ( scalar @$contacts ); if ( @extra ) { - $c->stash->{report_meta} = { map { $_->{name} => $_ } @extra }; + $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @extra }; $c->stash->{report}->set_extra_fields( @extra ); } } @@ -1232,10 +1270,9 @@ sub process_confirmation : Private { } # We have an unconfirmed problem + $problem->confirm; $problem->update( { - state => 'confirmed', - confirmed => \'current_timestamp', lastupdate => \'current_timestamp', } ); @@ -1401,7 +1438,8 @@ sub generate_map : Private { pins => [ { latitude => $latitude, longitude => $longitude, - colour => 'green', # 'yellow', + draggable => 1, + colour => $c->cobrand->pin_new_report_colour, } ], ); } @@ -1504,8 +1542,17 @@ sub redirect_to_around : Private { foreach (qw(pc zoom)) { $params->{$_} = $c->get_param($_); } + + if (my $pc_override = $c->get_param('pc_override')) { + delete $params->{lat}; + delete $params->{lon}; + $params->{pc} = $pc_override; + } + + my $csv = Text::CSV->new; foreach (qw(status filter_category)) { - $params->{$_} = join(',', $c->get_param_list($_, 1)); + $csv->combine($c->get_param_list($_, 1)); + $params->{$_} = $csv->string; } # delete empty values diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index 4a5b8db5d..cbedf7a01 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -5,6 +5,7 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } use Path::Class; +use List::Util 'first'; use Utils; =head1 NAME @@ -23,14 +24,14 @@ sub report_update : Path : Args(0) { $c->forward('initialize_update'); $c->forward('load_problem'); $c->forward('check_form_submitted') - or $c->go( '/report/display', [ $c->stash->{problem}->id ] ); + or $c->go( '/report/display', [ $c->stash->{problem}->id ], [] ); $c->forward('/auth/check_csrf_token'); $c->forward('process_update'); $c->forward('process_user'); $c->forward('/photo/process_photo'); $c->forward('check_for_errors') - or $c->go( '/report/display', [ $c->stash->{problem}->id ] ); + or $c->go( '/report/display', [ $c->stash->{problem}->id ], [] ); $c->forward('save_update'); $c->forward('redirect_or_confirm_creation'); @@ -99,7 +100,10 @@ sub process_user : Private { # Extract all the params to a hash to make them easier to work with my %params = map { $_ => $c->get_param($_) } - ( 'username', 'name', 'password_register', 'fms_extra_title' ); + ( 'name', 'password_register', 'fms_extra_title' ); + + # Update form includes two username fields: #form_username_register and #form_username_sign_in + $params{username} = (first { $_ } $c->get_param_list('username')) || ''; # Extra block to use 'last' if ( $c->user_exists ) { { @@ -241,6 +245,7 @@ This makes sure we only proceed to processing if we've had the form submitted sub check_form_submitted : Private { my ( $self, $c ) = @_; return if $c->stash->{problem}->get_extra_metadata('closed_updates'); + return if $c->cobrand->call_hook(updates_disallowed => $c->stash->{problem}); return $c->get_param('submit_update') || ''; } @@ -342,7 +347,7 @@ sub check_for_errors : Private { my $state = $c->get_param('state'); if ( $state && $state ne $c->stash->{update}->problem->state ) { my $error = 0; - $error = 1 unless $c->user && $c->user->belongs_to_body( $c->stash->{update}->problem->bodies_str ); + $error = 1 unless $c->user && ($c->user->is_superuser || $c->user->belongs_to_body($c->stash->{update}->problem->bodies_str)); $error = 1 unless grep { $state eq $_ } FixMyStreet::DB::Result::Problem->visible_states(); if ( $error ) { $c->stash->{errors} ||= []; diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index dc9e2c913..49bdce379 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -6,7 +6,7 @@ use JSON::MaybeXS; use List::MoreUtils qw(any); use Path::Tiny; use RABX; -use mySociety::MaPit; +use FixMyStreet::MapIt; BEGIN { extends 'Catalyst::Controller'; } @@ -31,26 +31,7 @@ Show the summary page of all reports. sub index : Path : Args(0) { my ( $self, $c ) = @_; - # Zurich goes straight to map page, with all reports - if ( $c->cobrand->moniker eq 'zurich' ) { - $c->forward( 'stash_report_filter_status' ); - $c->forward( 'load_and_group_problems' ); - $c->stash->{body} = { id => 0 }; # So template can fetch the list - - if ($c->get_param('ajax')) { - $c->detach('ajax', [ 'reports/_problem-list.html' ]); - } - - my $pins = $c->stash->{pins}; - $c->stash->{page} = 'reports'; - FixMyStreet::Map::display_map( - $c, - latitude => @$pins ? $pins->[0]{latitude} : 0, - longitude => @$pins ? $pins->[0]{longitude} : 0, - area => 274456, - pins => $pins, - any_zoom => 1, - ); + if ( $c->cobrand->call_hook('report_page_data') ) { return 1; } @@ -59,14 +40,7 @@ sub index : Path : Args(0) { $c->detach( 'redirect_body' ); } - if (my $body = $c->get_param('body')) { - $body = $c->model('DB::Body')->find( { id => $body } ); - if ($body) { - $body = $c->cobrand->short_name($body); - $c->res->redirect("/reports/$body"); - $c->detach; - } - } + $c->forward('display_body_stats'); my $dashboard = $c->forward('load_dashboard_data'); @@ -92,13 +66,34 @@ sub index : Path : Args(0) { $c->stash->{children} = $children; } } else { - my @bodies = $c->model('DB::Body')->active->translated->with_area_count->all_sorted; + my @bodies = $c->model('DB::Body')->search(undef, { + columns => [ "id", "name" ], + })->active->translated->with_area_count->all_sorted; @bodies = @{$c->cobrand->call_hook('reports_hook_restrict_bodies_list', \@bodies) || \@bodies }; $c->stash->{bodies} = \@bodies; } # Down here so that error pages aren't cached. - $c->response->header('Cache-Control' => 'max-age=3600'); + my $max_age = FixMyStreet->config('CACHE_TIMEOUT') // 3600; + $c->response->header('Cache-Control' => 'max-age=' . $max_age); +} + +=head2 display_body_stats + +Show the stats for a body if body param is set. + +=cut + +sub display_body_stats : Private { + my ( $self, $c ) = @_; + if (my $body = $c->get_param('body')) { + $body = $c->model('DB::Body')->find( { id => $body } ); + if ($body) { + $body = $c->cobrand->short_name($body); + $c->res->redirect("/reports/$body"); + $c->detach; + } + } } =head2 body @@ -123,7 +118,7 @@ sub ward : Path : Args(2) { $c->forward('/auth/get_csrf_token'); - my @wards = split /\|/, $ward || ""; + my @wards = $c->get_param('wards') ? $c->get_param_list('wards', 1) : split /\|/, $ward || ""; $c->forward( 'body_check', [ $body ] ); # If viewing multiple wards, rewrite the url from @@ -150,6 +145,8 @@ sub ward : Path : Args(2) { $c->go('index'); } + $c->stash->{page} = 'reports'; # So the map knows to make clickable pins + $c->forward( 'ward_check', [ @wards ] ) if @wards; $c->forward( 'check_canonical_url', [ $body ] ); @@ -157,7 +154,8 @@ sub ward : Path : Args(2) { $c->forward( 'load_and_group_problems' ); if ($c->get_param('ajax')) { - $c->detach('ajax', [ 'reports/_problem-list.html' ]); + my $ajax_template = $c->stash->{ajax_template} || 'reports/_problem-list.html'; + $c->detach('ajax', [ $ajax_template ]); } $c->stash->{rss_url} = '/rss/reports/' . $body_short; @@ -167,16 +165,15 @@ sub ward : Path : Args(2) { $c->stash->{stats} = $c->cobrand->get_report_stats(); my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, { - columns => [ 'category', 'extra' ], + columns => [ 'id', 'category', 'extra' ], distinct => 1, order_by => [ 'category' ], } )->all; $c->stash->{filter_categories} = \@categories; $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) }; - my $pins = $c->stash->{pins}; + my $pins = $c->stash->{pins} || []; - $c->stash->{page} = 'reports'; # So the map knows to make clickable pins my %map_params = ( latitude => @$pins ? $pins->[0]{latitude} : 0, longitude => @$pins ? $pins->[0]{longitude} : 0, @@ -223,7 +220,7 @@ sub rss_area_ward : Path('/rss/area') : Args(2) { return if $c->cobrand->reports_body_check( $c, $area ); # We must now have a string to check on mapit - my $areas = mySociety::MaPit::call( 'areas', $area, + my $areas = FixMyStreet::MapIt::call( 'areas', $area, type => $c->cobrand->area_types, ); @@ -394,13 +391,14 @@ sub ward_check : Private { $parent_id = $c->stash->{area}->{id}; } - my $qw = mySociety::MaPit::call('area/children', [ $parent_id ], + my $qw = FixMyStreet::MapIt::call('area/children', [ $parent_id ], type => $c->cobrand->area_types_children, ); - my %names = map { $_ => 1 } @wards; + my %names = map { $c->cobrand->short_name({ name => $_ }) => 1 } @wards; my @areas; foreach my $area (sort { $a->{name} cmp $b->{name} } values %$qw) { - push @areas, $area if $names{$area->{name}}; + my $name = $c->cobrand->short_name($area); + push @areas, $area if $names{$name}; } if (@areas) { $c->stash->{ward} = $areas[0] if @areas == 1; @@ -453,7 +451,7 @@ sub summary : Private { # required to stop errors in generate_grouped_data $c->stash->{q_state} = ''; - $c->stash->{ward} = $c->get_param('area'); + $c->stash->{ward} = [ $c->get_param('area') || () ]; $c->stash->{start_date} = $dtf->format_date($start_date); $c->stash->{end_date} = $c->get_param('end_date'); @@ -465,7 +463,7 @@ sub summary : Private { $c->forward('/admin/fetch_contacts'); $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; - $c->forward('/dashboard/construct_rs_filter'); + $c->forward('/dashboard/construct_rs_filter', []); if ( $c->get_param('csv') ) { $c->detach('export_summary_csv'); @@ -481,7 +479,7 @@ sub export_summary_csv : Private { my ( $self, $c ) = @_; $c->stash->{csv} = { - problems => $c->stash->{problems_rs}->search_rs({}, { + objects => $c->stash->{objects_rs}->search_rs({}, { rows => 100, order_by => { '-desc' => 'me.confirmed' }, }), @@ -557,16 +555,12 @@ sub load_and_group_problems : Private { my $states = $c->stash->{filter_problem_states}; my $where = { - state => [ keys %$states ] + 'me.state' => [ keys %$states ] }; - my $body = $c->stash->{body}; # Might be undef + $c->forward('check_non_public_reports_permission', [ $where ] ); - if ($c->user_exists && ($c->user->is_superuser || ($body && $c->user->has_permission_to('report_inspect', $body->id)))) { - # See all reports, no restriction - } else { - $where->{non_public} = 0; - } + my $body = $c->stash->{body}; # Might be undef my $filter = { order_by => $c->stash->{sort_order}, @@ -620,12 +614,21 @@ sub load_and_group_problems : Private { $where->{longitude} = { '>=', $min_lon, '<', $max_lon }; } - $problems = $problems->search( - $where, - $filter - )->include_comment_counts->page( $page ); + my $cobrand_problems = $c->cobrand->call_hook('munge_load_and_group_problems', $where, $filter); + + # JS will request the same (or more) data client side + return if $c->get_param('js'); - $c->stash->{pager} = $problems->pager; + if ($cobrand_problems) { + $problems = $cobrand_problems; + } else { + $problems = $problems->search( + $where, + $filter + )->include_comment_counts->page( $page ); + + $c->stash->{pager} = $problems->pager; + } my ( %problems, @pins ); while ( my $problem = $problems->next ) { @@ -633,19 +636,11 @@ sub load_and_group_problems : Private { add_row( $c, $problem, 0, \%problems, \@pins ); next; } - if ( !$problem->bodies_str ) { - # Problem was not sent to any body, add to all possible areas XXX - my $a = $problem->areas; # Store, as otherwise is looked up every iteration. - while ($a =~ /,(\d+)(?=,)/g) { - add_row( $c, $problem, $1, \%problems, \@pins ); - } - } else { - # Add to bodies it was sent to - my $bodies = $problem->bodies_str_ids; - foreach ( @$bodies ) { - next if $_ != $body->id; - add_row( $c, $problem, $_, \%problems, \@pins ); - } + # Add to bodies it was sent to + my $bodies = $problem->bodies_str_ids; + foreach ( @$bodies ) { + next if $_ != $body->id; + add_row( $c, $problem, $_, \%problems, \@pins ); } } @@ -657,6 +652,34 @@ sub load_and_group_problems : Private { return 1; } + +sub check_non_public_reports_permission : Private { + my ($self, $c, $where) = @_; + + if ( $c->user_exists ) { + my $user_has_permission; + + if ( $c->user->is_super_user ) { + $user_has_permission = 1; + } else { + my $body = $c->stash->{body}; + + $user_has_permission = $body && ( + $c->user->has_permission_to('report_inspect', $body->id) || + $c->user->has_permission_to('report_mark_private', $body->id) + ); + } + + if ( $user_has_permission ) { + $where->{non_public} = 1 if $c->stash->{only_non_public}; + } else { + $where->{non_public} = 0; + } + } else { + $where->{non_public} = 0; + } +} + sub redirect_index : Private { my ( $self, $c ) = @_; my $url = '/reports'; @@ -678,7 +701,7 @@ sub stash_report_filter_status : Private { my ( $self, $c ) = @_; my @status = $c->get_param_list('status', 1); - @status = ($c->cobrand->on_map_default_status) unless @status; + @status = ($c->stash->{page} eq 'my' ? 'all' : $c->cobrand->on_map_default_status) unless @status; my %status = map { $_ => 1 } @status; my %filter_problem_states; @@ -729,6 +752,10 @@ sub stash_report_filter_status : Private { } } + if ($status{non_public}) { + $c->stash->{only_non_public} = 1; + } + if (keys %filter_problem_states == 0) { my $s = FixMyStreet::DB::Result::Problem->open_states(); %filter_problem_states = (%filter_problem_states, %$s); @@ -758,10 +785,13 @@ sub stash_report_sort : Private { $sort =~ /^(updated|created|comments)-(desc|asc)$/; my $order_by = $types{$1} || $1; + # field to use for report age cutoff + $c->stash->{report_age_field} = $order_by eq 'comment_count' ? 'lastupdate' : $order_by; my $dir = $2; $order_by = { -desc => $order_by } if $dir eq 'desc'; $c->stash->{sort_order} = $order_by; + return 1; } @@ -779,7 +809,8 @@ sub ajax : Private { my @pins = map { my $p = $_; - [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title} ] + # lat, lon, 'colour', ID, title, type/size, draggable + [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ] } @{$c->stash->{pins}}; my $list_html = $c->render_fragment($template); diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm index 7f70623ae..340c930c2 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -39,6 +39,7 @@ sub auto : Private { # decide which cobrand this request should use $c->setup_request(); + $c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed'); return 1; } diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm index e1da4445d..443e45b93 100755 --- a/perllib/FixMyStreet/App/Controller/Rss.pm +++ b/perllib/FixMyStreet/App/Controller/Rss.pm @@ -11,7 +11,7 @@ use FixMyStreet::App::Model::PhotoSet; use FixMyStreet::Gaze; use mySociety::Locale; -use mySociety::MaPit; +use FixMyStreet::MapIt; use Lingua::EN::Inflect qw(ORD); BEGIN { extends 'Catalyst::Controller'; } @@ -66,7 +66,7 @@ sub reports_in_area : LocalRegex('^area/(\d+)$') { my ( $self, $c ) = @_; my $id = $c->req->captures->[0]; - my $area = mySociety::MaPit::call('area', $id); + my $area = FixMyStreet::MapIt::call('area', $id); $c->stash->{type} = 'area_problems'; $c->stash->{qs} = '/' . $id; $c->stash->{db_params} = [ $id ]; diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm index bb6140e0a..659d763de 100644 --- a/perllib/FixMyStreet/App/Controller/Tokens.pm +++ b/perllib/FixMyStreet/App/Controller/Tokens.pm @@ -28,17 +28,6 @@ problem but are not logged in. sub confirm_problem : Path('/P') { my ( $self, $c, $token_code ) = @_; - if ($token_code eq '_test_') { - $c->stash->{report} = { - id => 123, - title => 'Title of Report', - bodies_str => '1', - url => '/report/123', - service => $c->get_param('service'), - }; - return; - } - my $auth_token = $c->forward( 'load_auth_token', [ $token_code, 'problem' ] ); @@ -88,11 +77,6 @@ alert but are not logged in. sub confirm_alert : Path('/A') { my ( $self, $c, $token_code ) = @_; - if ($token_code eq '_test_') { - $c->stash->{confirm_type} = $c->get_param('confirm_type'); - return; - } - my $auth_token = $c->forward( 'load_auth_token', [ $token_code, 'alert' ] ); # Load the alert @@ -134,16 +118,6 @@ update but are not logged in. sub confirm_update : Path('/C') { my ( $self, $c, $token_code ) = @_; - if ($token_code eq '_test_') { - $c->stash->{problem} = { - id => 123, - title => 'Title of Report', - bodies_str => '1', - url => '/report/123', - }; - return; - } - my $auth_token = $c->forward( 'load_auth_token', [ $token_code, 'comment' ] ); diff --git a/perllib/FixMyStreet/App/Model/PhotoSet.pm b/perllib/FixMyStreet/App/Model/PhotoSet.pm index 8fcc1700e..58b352c73 100644 --- a/perllib/FixMyStreet/App/Model/PhotoSet.pm +++ b/perllib/FixMyStreet/App/Model/PhotoSet.pm @@ -3,21 +3,18 @@ package FixMyStreet::App::Model::PhotoSet; # TODO this isn't a Cat model, rename to something else use Moose; -use Path::Tiny 'path'; - -my $IM = eval { - require Image::Magick; - Image::Magick->import; - 1; -}; use Scalar::Util 'openhandle', 'blessed'; -use Digest::SHA qw(sha1_hex); use Image::Size; use IPC::Cmd qw(can_run); use IPC::Open3; use MIME::Base64; +use FixMyStreet; +use FixMyStreet::ImageMagick; +use FixMyStreet::PhotoStorage; + +# Attached Catalyst app, if present, for feeding back errors during photo upload has c => ( is => 'ro', ); @@ -57,27 +54,28 @@ has data_items => ( # either a) split from db_data or b) provided by photo uploa my $self = shift; my $data = $self->db_data or return []; - return [$data] if (detect_type($data)); + return [$data] if ($self->storage->detect_type($data)); return [ split ',' => $data ]; }, ); -has upload_dir => ( +has storage => ( is => 'ro', lazy => 1, default => sub { - path(FixMyStreet->config('UPLOAD_DIR'))->absolute(FixMyStreet->path_to()); - }, + return FixMyStreet::PhotoStorage::backend; + } ); -sub detect_type { - return 'jpeg' if $_[0] =~ /^\x{ff}\x{d8}/; - return 'png' if $_[0] =~ /^\x{89}\x{50}/; - return 'tiff' if $_[0] =~ /^II/; - return 'gif' if $_[0] =~ /^GIF/; - return ''; -} +has symlinkable => ( + is => 'ro', + lazy => 1, + default => sub { + my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS'); + return $cfg ? $cfg->{SYMLINK_FULL_SIZE} : 0; + } +); =head2 C<ids>, C<num_images>, C<get_id>, C<all_ids> @@ -166,25 +164,21 @@ has ids => ( # Arrayref of $fileid tuples (always, so post upload/raw data proc return (); } - # we have an image we can use - save it to the upload dir for storage - my $fileid = $self->get_fileid($photo_blob); - my $file = $self->get_file($fileid, $type); - $upload->copy_to( $file ); - return $file->basename; - + # we have an image we can use - save it to storage + $photo_blob = FixMyStreet::ImageMagick->new(blob => $photo_blob)->shrink('2048x2048')->as_blob; + return $self->storage->store_photo($photo_blob); } - if (my $type = detect_type($part)) { + + # It might be a raw file stored in the DB column... + if (my $type = $self->storage->detect_type($part)) { my $photo_blob = $part; - my $fileid = $self->get_fileid($photo_blob); - my $file = $self->get_file($fileid, $type); - $file->spew_raw($photo_blob); - return $file->basename; + return $self->storage->store_photo($photo_blob); + # TODO: Should this update the DB record with a pointer to the + # newly-stored file, instead of leaving it in the DB? } - my ($fileid, $type) = split /\./, $part; - $type ||= 'jpeg'; - if ($fileid && length($fileid) == 40) { - my $file = $self->get_file($fileid, $type); - $file->basename; + + if (my $key = $self->storage->validate_key($part)) { + $key; } else { # A bad hash, probably a bot spamming with bad data. (); @@ -194,25 +188,13 @@ has ids => ( # Arrayref of $fileid tuples (always, so post upload/raw data proc }, ); -sub get_fileid { - my ($self, $photo_blob) = @_; - return sha1_hex($photo_blob); -} - -sub get_file { - my ($self, $fileid, $type) = @_; - my $cache_dir = $self->upload_dir; - return path( $cache_dir, "$fileid.$type" ); -} - sub get_raw_image { my ($self, $index) = @_; my $filename = $self->get_id($index); - my ($fileid, $type) = split /\./, $filename; - my $file = $self->get_file($fileid, $type); - if ($file->exists) { - my $photo = $file->slurp_raw; + my ($photo, $type, $object) = $self->storage->retrieve_photo($filename); + if ($photo) { return { + $object ? (object => $object) : (), data => $photo, content_type => "image/$type", extension => $type, @@ -229,14 +211,21 @@ sub get_image_data { my $photo = $image->{data}; my $size = $args{size}; + + if ($self->symlinkable && $image->{object} && $size eq 'full') { + $image->{symlink} = delete $image->{object}; + return $image; + } + + my $im = FixMyStreet::ImageMagick->new(blob => $photo); if ( $size eq 'tn' ) { - $photo = _shrink( $photo, 'x100' ); + $photo = $im->shrink('x100')->as_blob; } elsif ( $size eq 'fp' ) { - $photo = _crop( $photo ); + $photo = $im->crop->as_blob; } elsif ( $size eq 'full' ) { # do nothing } else { - $photo = _shrink( $photo, $args{default} || '250x250' ); + $photo = $im->shrink($args{default} || '250x250')->as_blob; } return { @@ -298,7 +287,7 @@ sub rotate_image { return if $index > $#images; my $image = $self->get_raw_image($index); - $images[$index] = _rotate_image( $image->{data}, $direction ); + $images[$index] = FixMyStreet::ImageMagick->new(blob => $image->{data})->rotate($direction)->as_blob; my $new_set = (ref $self)->new({ data_items => \@images, @@ -310,47 +299,4 @@ sub rotate_image { return $new_set->data; # e.g. new comma-separated fileid } -sub _rotate_image { - my ($photo, $direction) = @_; - return $photo unless $IM; - my $image = Image::Magick->new; - $image->BlobToImage($photo); - my $err = $image->Rotate($direction); - return 0 if $err; - my @blobs = $image->ImageToBlob(); - undef $image; - return $blobs[0]; -} - - -# Shrinks a picture to the specified size, but keeping in proportion. -sub _shrink { - my ($photo, $size) = @_; - return $photo unless $IM; - my $image = Image::Magick->new; - $image->BlobToImage($photo); - my $err = $image->Scale(geometry => "$size>"); - throw Error::Simple("resize failed: $err") if "$err"; - $image->Strip(); - my @blobs = $image->ImageToBlob(); - undef $image; - return $blobs[0]; -} - -# Shrinks a picture to 90x60, cropping so that it is exactly that. -sub _crop { - my ($photo) = @_; - return $photo unless $IM; - my $image = Image::Magick->new; - $image->BlobToImage($photo); - my $err = $image->Resize( geometry => "90x60^" ); - throw Error::Simple("resize failed: $err") if "$err"; - $err = $image->Extent( geometry => '90x60', gravity => 'Center' ); - throw Error::Simple("resize failed: $err") if "$err"; - $image->Strip(); - my @blobs = $image->ImageToBlob(); - undef $image; - return $blobs[0]; -} - 1; diff --git a/perllib/FixMyStreet/Cobrand/Angus.pm b/perllib/FixMyStreet/Cobrand/Angus.pm deleted file mode 100644 index 87dcc1d96..000000000 --- a/perllib/FixMyStreet/Cobrand/Angus.pm +++ /dev/null @@ -1,132 +0,0 @@ -package FixMyStreet::Cobrand::Angus; -use parent 'FixMyStreet::Cobrand::UKCouncils'; - -use strict; -use warnings; - -sub council_area_id { return 2550; } -sub council_area { return 'Angus'; } -sub council_name { return 'Angus Council'; } -sub council_url { return 'angus'; } - -sub base_url { - my $self = shift; - return $self->next::method() if FixMyStreet->config('STAGING_SITE'); - return 'https://fix.angus.gov.uk'; -} - -sub enter_postcode_text { - my ($self) = @_; - return 'Enter an Angus postcode, or street name and area'; -} - -sub example_places { - return ( 'DD8 3AP', "Canmore Street" ); -} - -sub map_type { 'Angus' } - -sub default_show_name { 0 } - -sub disambiguate_location { - my $self = shift; - my $string = shift; - - return { - %{ $self->SUPER::disambiguate_location() }, - town => 'Angus', - centre => '56.7240845983561,-2.91774391131183', - span => '0.525195055746977,0.985870680170788', - bounds => [ 56.4616875530489, -3.40703662677109, 56.9868826087959, -2.4211659466003 ], - }; -} - -sub pin_colour { - my ( $self, $p, $context ) = @_; - return 'grey' if $p->state eq 'not responsible'; - return 'green' if $p->is_fixed || $p->is_closed; - return 'red' if $p->state eq 'confirmed'; - return 'yellow'; -} - -sub contact_email { - my $self = shift; - return join( '@', 'accessline', 'angus.gov.uk' ); -} - -=head2 temp_email_to_update, temp_update_contacts - -Temporary helper routines to update the extra for potholes (temporary setup -hack, cargo-culted from Harrogate, may in future be superseded either by -Open311/integration or a better mechanism for manually creating rich contacts). - -Can run with a script or command line like: - - bin/cron-wrapper perl -MFixMyStreet::App -MFixMyStreet::Cobrand::Angus -e \ - 'FixMyStreet::Cobrand::Angus->new({c => FixMyStreet::App->new})->temp_update_contacts' - -=cut - -sub temp_update_contacts { - my $self = shift; - - my $contact_rs = $self->{c}->model('DB::Contact'); - - my $body = FixMyStreet::DB->resultset('Body')->for_areas($self->council_area_id)->first; - - my $_update = sub { - my ($category, $field, $category_details) = @_; - # NB: we're accepting just 1 field, but supply as array [ $field ] - - my $contact = $contact_rs->find_or_create( - { - body => $body, - category => $category, - %{ $category_details || {} }, - }, - { - key => 'contacts_body_id_category_idx' - } - ); - - my %default = ( - variable => 'true', - order => '1', - required => 'no', - datatype => 'string', - datatype_description => 'a string', - ); - - if ($field->{datatype} || '' eq 'boolean') { - my $description = $field->{description}; - %default = ( - %default, - datatype => 'singlevaluelist', - datatype_description => 'Yes or No', - values => { value => [ - { key => ['No'], name => ['No'] }, - { key => ['Yes'], name => ['Yes'] }, - ] }, - ); - } - - $contact->update({ - # XXX: we're just setting extra with the expected layout, - # this could be encapsulated more nicely - extra => { _fields => [ { %default, %$field } ] }, - confirmed => 1, - deleted => 0, - editor => 'automated script', - whenedited => \'NOW()', - note => 'Edited by script as per requirements Jan 2016', - }); - }; - - $_update->( 'Street lighting', { - code => 'column_id', - description => 'Lamp post number', - }); - -} - -1; diff --git a/perllib/FixMyStreet/Cobrand/BathNES.pm b/perllib/FixMyStreet/Cobrand/BathNES.pm index 81080bed9..773edd3c3 100644 --- a/perllib/FixMyStreet/Cobrand/BathNES.pm +++ b/perllib/FixMyStreet/Cobrand/BathNES.pm @@ -4,6 +4,9 @@ use parent 'FixMyStreet::Cobrand::Whitelabel'; use strict; use warnings; +use Moo; +with 'FixMyStreet::Roles::ConfirmValidation'; + use LWP::Simple; use URI; use Try::Tiny; @@ -19,10 +22,7 @@ sub contact_email { return join( '@', 'councilconnect_rejections', 'bathnes.gov.uk' ); } -sub update_email { - my $self = shift; - return join( '@', 'highways', 'bathnes.gov.uk' ); -} +sub suggest_duplicates { 1 } sub admin_user_domain { 'bathnes.gov.uk' } @@ -34,6 +34,8 @@ sub base_url { sub map_type { 'BathNES' } +sub on_map_default_status { 'open' } + sub example_places { return ( 'BA1 1JQ', "Lansdown Grove" ); } @@ -91,13 +93,12 @@ sub send_questionnaires { 0 } sub enable_category_groups { 1 } -sub default_show_name { 0 } - sub default_map_zoom { 3 } sub map_js_extra { - my ($self, $c) = @_; + my $self = shift; + my $c = $self->{c}; return unless $c->user_exists; my $banes_user = $c->user->from_body && $c->user->from_body->areas->{$self->council_area_id}; @@ -152,7 +153,7 @@ sub available_permissions { return $permissions; } -sub report_sent_confirmation_email { 1 } +sub report_sent_confirmation_email { 'id' } sub lookup_usrn { my $self = shift; @@ -207,10 +208,60 @@ sub categories_restriction { 'me.send_method' => undef, # Open311 categories 'me.send_method' => '', # Open311 categories that have been edited in the admin 'me.send_method' => 'Email::BathNES', # Street Light Fault + 'me.send_method' => 'Blackhole', # Parks categories ] } ); } -sub dashboard_export_add_columns { +# Do a manual prefetch, as easier than sorting out quoting 'user' +sub _dashboard_user_lookup { + my $self = shift; + my $c = $self->{c}; + + # Fetch all the relevant user IDs, and look them up + my @user_ids = $c->stash->{objects_rs}->search({}, { columns => [ 'user_id' ] })->all; + @user_ids = map { $_->user_id } @user_ids; + @user_ids = $c->model('DB::User')->search( + { id => { -in => \@user_ids } }, + { columns => [ 'id', 'email', 'phone' ] })->all; + + # Plus all staff users for contributed_by lookup + push @user_ids, $c->model('DB::User')->search( + { from_body => { '!=' => undef } }, + { columns => [ 'id', 'email', 'phone' ] })->all; + + my %user_lookup = map { $_->id => { email => $_->email, phone => $_->phone } } @user_ids; + return \%user_lookup; +} + +sub dashboard_export_updates_add_columns { + my $self = shift; + my $c = $self->{c}; + + return unless $c->user->has_body_permission_to('export_extra_columns'); + + push @{$c->stash->{csv}->{headers}}, "Staff User"; + push @{$c->stash->{csv}->{headers}}, "User Email"; + push @{$c->stash->{csv}->{columns}}, "staff_user"; + push @{$c->stash->{csv}->{columns}}, "user_email"; + + my $user_lookup = $self->_dashboard_user_lookup; + + $c->stash->{csv}->{extra_data} = sub { + my $report = shift; + + my $staff_user = ''; + if ( my $contributed_by = $report->get_extra_metadata('contributed_by') ) { + $staff_user = $user_lookup->{$contributed_by}{email}; + } + + return { + user_email => $user_lookup->{$report->user_id}{email} || '', + staff_user => $staff_user, + }; + }; +} + +sub dashboard_export_problems_add_columns { my $self = shift; my $c = $self->{c}; @@ -220,39 +271,33 @@ sub dashboard_export_add_columns { @{ $c->stash->{csv}->{headers} }, "User Email", "User Phone", - "Reported As", "Staff User", "Attribute Data", - "Site Used", ]; $c->stash->{csv}->{columns} = [ @{ $c->stash->{csv}->{columns} }, "user_email", "user_phone", - "reported_as", "staff_user", "attribute_data", - "site_used", ]; + my $user_lookup = $self->_dashboard_user_lookup; + $c->stash->{csv}->{extra_data} = sub { my $report = shift; - my $reported_as = $report->get_extra_metadata('contributed_as') || ''; my $staff_user = ''; if ( my $contributed_by = $report->get_extra_metadata('contributed_by') ) { - $staff_user = $c->model('DB::User')->find({ id => $contributed_by })->email; + $staff_user = $user_lookup->{$contributed_by}{email}; } - my $site_used = $report->service || $report->cobrand || ''; my $attribute_data = join "; ", map { $_->{name} . " = " . $_->{value} } @{ $report->get_extra_fields }; return { - user_email => $report->user->email || '', - user_phone => $report->user->phone || '', - reported_as => $reported_as, + user_email => $user_lookup->{$report->user_id}{email} || '', + user_phone => $user_lookup->{$report->user_id}{phone} || '', staff_user => $staff_user, attribute_data => $attribute_data, - site_used => $site_used, }; }; } diff --git a/perllib/FixMyStreet/Cobrand/Borsetshire.pm b/perllib/FixMyStreet/Cobrand/Borsetshire.pm index d9b018d69..e721bee0f 100644 --- a/perllib/FixMyStreet/Cobrand/Borsetshire.pm +++ b/perllib/FixMyStreet/Cobrand/Borsetshire.pm @@ -31,4 +31,8 @@ sub send_questionnaires { sub bypass_password_checks { 1 } +sub enable_category_groups { 1 } + +sub suggest_duplicates { 1 } + 1; diff --git a/perllib/FixMyStreet/Cobrand/Bristol.pm b/perllib/FixMyStreet/Cobrand/Bristol.pm index 25dc5ab0a..fa2d3fabb 100644 --- a/perllib/FixMyStreet/Cobrand/Bristol.pm +++ b/perllib/FixMyStreet/Cobrand/Bristol.pm @@ -23,8 +23,6 @@ sub map_type { 'Bristol'; } -sub default_link_zoom { 6 } - sub disambiguate_location { my $self = shift; my $string = shift; @@ -77,4 +75,13 @@ sub open311_config { $params->{always_send_email} = 1; } +sub open311_contact_meta_override { + my ($self, $service, $contact, $meta) = @_; + + my %server_set = (easting => 1, northing => 1); + foreach (@$meta) { + $_->{automated} = 'server_set' if $server_set{$_->{code}}; + } +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Bromley.pm b/perllib/FixMyStreet/Cobrand/Bromley.pm index 5d14d0b01..341fb6a30 100644 --- a/perllib/FixMyStreet/Cobrand/Bromley.pm +++ b/perllib/FixMyStreet/Cobrand/Bromley.pm @@ -3,20 +3,48 @@ use parent 'FixMyStreet::Cobrand::UKCouncils'; use strict; use warnings; +use utf8; use DateTime::Format::W3CDTF; +use DateTime::Format::Flexible; +use Try::Tiny; +use FixMyStreet::DateRange; sub council_area_id { return 2482; } sub council_area { return 'Bromley'; } sub council_name { return 'Bromley Council'; } sub council_url { return 'bromley'; } +sub report_validation { + my ($self, $report, $errors) = @_; + + if ( length( $report->detail ) > 1750 ) { + $errors->{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), 1750 ); + } + + return $errors; +} + +# This makes sure that the subcategory Open311 attribute question is +# also stored in the report's subcategory column. This could be done +# in process_open311_extras, but seemed easier to keep that separate +sub report_new_munge_before_insert { + my ($self, $report) = @_; + + $report->subcategory($report->get_extra_field_value('service_sub_code')); +} + sub base_url { my $self = shift; return $self->next::method() if FixMyStreet->config('STAGING_SITE'); return 'https://fix.bromley.gov.uk'; } -sub default_show_name { 0 } +sub problems_on_map_restriction { + my ($self, $rs) = @_; + return $rs if FixMyStreet->staging_flag('skip_checks'); + my $tfl = FixMyStreet::DB->resultset('Body')->search({ name => 'TfL' })->first; + return $rs->to_body($tfl ? [ $self->body->id, $tfl->id ] : $self->body); +} sub disambiguate_location { my $self = shift; @@ -30,18 +58,24 @@ sub disambiguate_location { # a different Priory Avenue in Petts Wood # From Google maps search, "BR6 0PL" is a valid postcode for Old Priory Avenue if ($string =~/^old\s+priory\s+av\w*$/i) { - $string = 'Ramsden Road'; - $town = ', BR6 0PL'; + $town = 'BR6 0PL'; } # White Horse Hill is on boundary with Greenwich, so need a # specific postcode - $string = 'BR7 6DH' if $string =~ /^white\s+horse/i; + $town = 'BR7 6DH' if $string =~ /^white\s+horse/i; $town = '' if $string =~ /orpington/i; + $string =~ s/(, *)?br[12]$//i; + $town = 'Beckenham' if $string =~ s/(, *)?br3$//i; + $town = 'West Wickham' if $string =~ s/(, *)?br4$//i; + $town = 'Orpington' if $string =~ s/(, *)?br[56]$//i; + $town = 'Chislehurst' if $string =~ s/(, *)?br7$//i; + $town = 'Swanley' if $string =~ s/(, *)?br8$//i; return { %{ $self->SUPER::disambiguate_location() }, + string => $string, town => $town, centre => '51.366836,0.040623', span => '0.154963,0.24347', @@ -64,6 +98,7 @@ sub map_type { # Bromley pins always yellow sub pin_colour { my ( $self, $p, $context ) = @_; + return 'grey' if !$self->owns_problem( $p ); return 'yellow'; } @@ -92,6 +127,8 @@ sub contact_email { } sub contact_name { 'Bromley Council (do not reply)'; } +sub abuse_reports_only { 1; } + sub reports_per_page { return 20; } sub tweak_all_reports_map { @@ -104,6 +141,39 @@ sub tweak_all_reports_map { $c->stash->{map}->{any_zoom} = 0; $c->stash->{map}->{zoom} = 11; } + + # A place where this can happen + return unless $c->stash->{template} && $c->stash->{template} eq 'about/heatmap.html'; + + my $children = $c->stash->{body}->first_area_children; + foreach (values %$children) { + $_->{url} = $c->uri_for( $c->stash->{body_url} + . '/' . $c->cobrand->short_name( $_ ) + ); + } + $c->stash->{children} = $children; + + my %subcats = $self->subcategories; + my $filter = $c->stash->{filter_categories}; + my @new_contacts; + foreach (@$filter) { + push @new_contacts, $_; + foreach (@{$subcats{$_->id}}) { + push @new_contacts, { + category => $_->{key}, + category_display => (" " x 4) . $_->{name}, + }; + } + } + $c->stash->{filter_categories} = \@new_contacts; + + if (!%{$c->stash->{filter_category}}) { + my $cats = $c->user->categories; + my $subcats = $c->user->get_extra_metadata('subcategories') || []; + $c->stash->{filter_category} = { map { $_ => 1 } @$cats, @$subcats } if @$cats || @$subcats; + } + + $c->stash->{ward_hash} = { map { $_->{id} => 1 } @{$c->stash->{wards}} } if $c->stash->{wards}; } sub title_list { @@ -117,6 +187,7 @@ sub open311_config { my $title = $row->title; foreach (@$extra) { + next unless $_->{value}; $title .= ' | ID: ' . $_->{value} if $_->{name} eq 'feature_id'; $title .= ' | PROW ID: ' . $_->{value} if $_->{name} eq 'prow_reference'; } @@ -150,5 +221,227 @@ sub open311_config { $params->{extended_description} = 0; } +sub open311_config_updates { + my ($self, $params) = @_; + $params->{endpoints} = { + service_request_updates => 'update.xml', + update => 'update.xml' + }; +} + +sub open311_pre_send { + my ($self, $row, $open311) = @_; + + my $extra = $row->extra || {}; + unless ( $extra->{title} ) { + $extra->{title} = $row->user->title; + $row->extra( $extra ); + } +} + +sub open311_munge_update_params { + my ($self, $params, $comment, $body) = @_; + delete $params->{update_id}; + $params->{public_anonymity_required} = $comment->anonymous ? 'TRUE' : 'FALSE', + $params->{update_id_ext} = $comment->id; + $params->{service_request_id_ext} = $comment->problem->id; +} + +sub open311_contact_meta_override { + my ($self, $service, $contact, $meta) = @_; + + $contact->set_extra_metadata( id_field => 'service_request_id_ext'); + + my %server_set = (easting => 1, northing => 1, service_request_id_ext => 1); + foreach (@$meta) { + $_->{automated} = 'server_set' if $server_set{$_->{code}}; + } + + # Lights we want to store feature ID, PROW on all categories. + push @$meta, { + code => 'prow_reference', + datatype => 'string', + description => 'Right of way reference', + order => 101, + required => 'false', + variable => 'true', + automated => 'hidden_field', + }; + push @$meta, { + code => 'feature_id', + datatype => 'string', + description => 'Feature ID', + order => 100, + required => 'false', + variable => 'true', + automated => 'hidden_field', + } if $service->{service_code} eq 'SLRS'; + + my @override = qw( + requested_datetime + report_url + title + last_name + email + report_title + public_anonymity_required + email_alerts_requested + ); + my %ignore = map { $_ => 1 } @override; + @$meta = grep { !$ignore{$_->{code}} } @$meta; +} + +# If any subcategories ticked in user edit admin, make sure they're saved. +sub admin_user_edit_extra_data { + my $self = shift; + my $c = $self->{c}; + my $user = $c->stash->{user}; + + return unless $c->get_param('submit') && $user && $user->from_body; + + $c->stash->{body} = $user->from_body; + my %subcats = $self->subcategories; + my @subcat_ids = map { $_->{key} } map { @$_ } values %subcats; + my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @subcat_ids; + $user->set_extra_metadata('subcategories', \@new_contact_ids); +} + +# Returns a hash of contact ID => list of subcategories +# (which are stored as Open311 attribute questions) +sub subcategories { + my $self = shift; + + my @c = $self->body->contacts->not_deleted->all; + my %subcategories; + foreach my $contact (@c) { + my @fields = @{$contact->get_extra_fields}; + my ($field) = grep { $_->{code} eq 'service_sub_code' } @fields; + $subcategories{$contact->id} = $field->{values} || []; + } + return %subcategories; +} + +# Returns the list of categories, with Bromley subcategories added, +# for the user edit admin interface +sub add_admin_subcategories { + my $self = shift; + my $c = $self->{c}; + + my $user = $c->stash->{user}; + my @subcategories = @{$user->get_extra_metadata('subcategories') || []}; + my %active_contacts = map { $_ => 1 } @subcategories; + + my %subcats = $self->subcategories; + my $contacts = $c->stash->{contacts}; + my @new_contacts; + foreach (@$contacts) { + push @new_contacts, $_; + foreach (@{$subcats{$_->{id}}}) { + push @new_contacts, { + id => $_->{key}, + category => (" " x 4) . $_->{name}, + active => $active_contacts{$_->{key}}, + }; + } + } + return \@new_contacts; +} + +sub about_hook { + my $self = shift; + my $c = $self->{c}; + + # Display a special custom dashboard page, with heatmap + if ($c->stash->{template} eq 'about/heatmap.html') { + $c->forward('/dashboard/check_page_allowed'); + # We want a special sidebar + $c->stash->{ajax_template} = "about/heatmap-list.html"; + $c->set_param('js', 1) unless $c->get_param('ajax'); # Want to load pins client-side + $c->forward('/reports/body', [ 'Bromley' ]); + } +} + +# On heatmap page, include querying on subcategories, wards, dates, provided +sub munge_load_and_group_problems { + my ($self, $where, $filter) = @_; + my $c = $self->{c}; + + return unless $c->stash->{template} && $c->stash->{template} eq 'about/heatmap.html'; + + if (!$where->{category}) { + my $cats = $c->user->categories; + my $subcats = $c->user->get_extra_metadata('subcategories') || []; + $where->{category} = [ @$cats, @$subcats ] if @$cats || @$subcats; + } + + my %subcats = $self->subcategories; + my $subcat; + my %chosen = map { $_ => 1 } @{$where->{category} || []}; + my @subcat = grep { $chosen{$_} } map { $_->{key} } map { @$_ } values %subcats; + if (@subcat) { + my %chosen = map { $_ => 1 } @subcat; + $where->{'-or'} = { + category => [ grep { !$chosen{$_} } @{$where->{category}} ], + subcategory => \@subcat, + }; + delete $where->{category}; + } + + # Wards + my @areas = @{$c->user->area_ids || []}; + # Want to get everything if nothing given in an ajax call + if (!$c->stash->{wards} && @areas) { + $c->stash->{wards} = [ map { { id => $_ } } @areas ]; + $where->{areas} = [ + map { { 'like', '%,' . $_ . ',%' } } @areas + ]; + } + + # Date range + my $start_default = DateTime->today(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(months => 1); + $c->stash->{start_date} = $c->get_param('start_date') || $start_default->strftime('%Y-%m-%d'); + $c->stash->{end_date} = $c->get_param('end_date'); + + my $range = FixMyStreet::DateRange->new( + start_date => $c->stash->{start_date}, + start_default => $start_default, + end_date => $c->stash->{end_date}, + formatter => $c->model('DB')->storage->datetime_parser, + ); + $where->{'me.confirmed'} = $range->sql; + + delete $filter->{rows}; + + # Load the relevant stuff for the sidebar as well + my $problems = $self->problems->search($where, $filter); + + $c->stash->{five_newest} = [ $problems->search(undef, { + rows => 5, + order_by => { -desc => 'confirmed' }, + })->all ]; + + $c->stash->{ten_oldest} = [ $problems->search({ + 'me.state' => [ FixMyStreet::DB::Result::Problem->open_states() ], + }, { + rows => 10, + order_by => 'lastupdate', + })->all ]; + + my $params = { map { my $n = $_; s/me\./problem\./; $_ => $where->{$n} } keys %$where }; + my @c = $c->model('DB::Comment')->to_body($self->body)->search({ + %$params, + 'me.user_id' => { -not_in => [ $c->user->id, $self->body->comment_user_id ] }, + 'me.state' => 'confirmed', + }, { + columns => 'problem_id', + group_by => 'problem_id', + order_by => { -desc => \'max(me.confirmed)' }, + rows => 5, + })->all; + $c->stash->{five_commented} = [ map { $_->problem } @c ]; + + return $problems; +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm index a5e45d5a9..3a33d6f58 100644 --- a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm +++ b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm @@ -4,16 +4,15 @@ use parent 'FixMyStreet::Cobrand::UKCouncils'; use strict; use warnings; -use LWP::Simple; -use URI; -use Try::Tiny; -use JSON::MaybeXS; +use Moo; +with 'FixMyStreet::Roles::ConfirmValidation'; sub council_area_id { return 2217; } sub council_area { return 'Buckinghamshire'; } sub council_name { return 'Buckinghamshire County Council'; } sub council_url { return 'buckinghamshire'; } + sub example_places { return ( 'HP19 7QF', "Walton Road" ); } @@ -43,6 +42,8 @@ sub disambiguate_location { }; } +sub on_map_default_status { 'open' } + sub pin_colour { my ( $self, $p, $context ) = @_; return 'grey' if $p->state eq 'not responsible'; @@ -51,6 +52,8 @@ sub pin_colour { return 'yellow'; } +sub admin_user_domain { 'buckscc.gov.uk' } + sub contact_email { my $self = shift; return join( '@', 'fixmystreetbs', 'email.buckscc.gov.uk' ); @@ -87,12 +90,152 @@ sub open311_config { $row->set_extra_fields(@$extra); } +sub open311_pre_send { + my ($self, $row, $open311) = @_; + + return unless $row->extra; + my $extra = $row->get_extra_fields; + if (@$extra) { + @$extra = grep { $_->{name} ne 'road-placement' } @$extra; + $row->set_extra_fields(@$extra); + } +} + +sub open311_post_send { + my ($self, $row, $h) = @_; + + # Check Open311 was successful + return unless $row->external_id; + + # For certain categories, send an email also + my $addresses = { + 'Flytipping' => [ join('@', 'illegaldumpingcosts', $self->admin_user_domain), "TfB" ], + 'Blocked drain' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ], + 'Ditch issue' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ], + 'Flooded subway' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ], + }; + my $dest = $addresses->{$row->category}; + return unless $dest; + + my $sender = FixMyStreet::SendReport::Email->new( to => [ $dest ] ); + $sender->send($row, $h); +} + +sub open311_config_updates { + my ($self, $params) = @_; + $params->{mark_reopen} = 1; +} + +sub open311_contact_meta_override { + my ($self, $service, $contact, $meta) = @_; + + push @$meta, { + code => 'road-placement', + datatype => 'singlevaluelist', + description => 'Is the fly-tip located on', + order => 100, + required => 'true', + variable => 'true', + values => [ + { key => 'road', name => 'The road' }, + { key => 'off-road', name => 'Off the road/on a verge' }, + ], + } if $service->{service_name} eq 'Flytipping'; +} + +sub process_open311_extras { + my ($self, $c, $body, $extra) = @_; + + return unless $c->stash->{report}; # Don't care about updates + + $self->flytipping_body_fix( + $c->stash->{report}, + $c->get_param('road-placement'), + $c->stash->{field_errors}, + ); +} + +sub flytipping_body_fix { + my ($self, $report, $road_placement, $errors) = @_; + + return unless $report->category eq 'Flytipping'; + + if ($report->bodies_str =~ /,/) { + # Sent to both councils in the area + my @bodies = values %{$report->bodies}; + my $county = (grep { $_->name =~ /^Buckinghamshire/ } @bodies)[0]; + my $district = (grep { $_->name !~ /^Buckinghamshire/ } @bodies)[0]; + # Decide which to send to based upon the answer to the extra question: + if ($road_placement eq 'road') { + $report->bodies_str($county->id); + } elsif ($road_placement eq 'off-road') { + $report->bodies_str($district->id); + } + } else { + # If the report is only being sent to the district, we do + # not care about the road question, if it is missing + if (!$report->to_body_named('Buckinghamshire')) { + delete $errors->{'road-placement'}; + } + } +} + +sub filter_report_description { + my ($self, $description) = @_; + + # this allows _ in the domain name but I figure it's unlikely to + # generate false positives so lets go with that for the same of + # a simpler regex + $description =~ s/\b[\w.!#$%&'*+\-\/=?^_{|}~]+\@[\w\-]+\.[^ ]+\b//g; + $description =~ s/ (?: \+ \d{2} \s? | \b 0 ) (?: + \d{2} \s? \d{4} \s? \d{4} # 0xx( )xxxx( )xxxx + | \d{3} \s \d{3} \s? \d{4} # 0xxx xxx( )xxxx + | \d{3} \s? \d{2} \s \d{4,5} # 0xxx( )xx xxxx(x) + | \d{4} \s \d{5,6} # 0xxxx xxxxx(x) + ) \b //gx; + + return $description; +} + sub map_type { 'Buckinghamshire' } sub default_map_zoom { 3 } sub enable_category_groups { 1 } +sub _dashboard_export_add_columns { + my $self = shift; + my $c = $self->{c}; + + push @{$c->stash->{csv}->{headers}}, "Staff User"; + push @{$c->stash->{csv}->{columns}}, "staff_user"; + + # All staff users, for contributed_by lookup + my @user_ids = $c->model('DB::User')->search( + { from_body => $self->body->id }, + { columns => [ 'id', 'email', ] })->all; + my %user_lookup = map { $_->id => $_->email } @user_ids; + + $c->stash->{csv}->{extra_data} = sub { + my $report = shift; + my $staff_user = ''; + if (my $contributed_by = $report->get_extra_metadata('contributed_by')) { + $staff_user = $user_lookup{$contributed_by}; + } + return { + staff_user => $staff_user, + }; + }; +} + +sub dashboard_export_updates_add_columns { + shift->_dashboard_export_add_columns; +} + +sub dashboard_export_problems_add_columns { + shift->_dashboard_export_add_columns; +} + # Enable adding/editing of parish councils in the admin sub add_extra_areas { my ($self, $areas) = @_; @@ -304,7 +447,7 @@ sub should_skip_sending_update { sub disable_phone_number_entry { 1 } -sub report_sent_confirmation_email { 1 } +sub report_sent_confirmation_email { 'external_id' } sub is_council_with_case_management { 1 } @@ -315,88 +458,25 @@ sub categories_restriction { my ($self, $rs) = @_; # Buckinghamshire is a two-tier council, but only want to display # county-level categories on their cobrand. - return $rs->search( { 'body.id' => 2217 } ); + return $rs->search( [ { 'body_areas.area_id' => 2217 }, { category => 'Flytipping' } ], { join => { body => 'body_areas' } }); } -sub lookup_site_code { - my $self = shift; - my $row = shift; - - my $buffer = 200; # metres - my ($x, $y) = $row->local_coords; - my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer); - - my $uri = URI->new("https://tilma.mysociety.org/mapserver/bucks"); - $uri->query_form( - REQUEST => "GetFeature", - SERVICE => "WFS", - SRSNAME => "urn:ogc:def:crs:EPSG::27700", - TYPENAME => "Whole_Street", - VERSION => "1.1.0", - outputformat => "geojson", - BBOX => "$w,$s,$e,$n" - ); - - my $response = get($uri); - - my $j = JSON->new->utf8->allow_nonref; - try { - $j = $j->decode($response); - } catch { - # There was either no asset found, or an error with the WFS - # call - in either case let's just proceed without the USRN. - return ''; - }; - - # We have a list of features, and we want to find the one closest to the - # report location. - my $site_code = ''; - my $nearest; - - # There are only certain features we care about, the rest can be ignored. - my @valid_types = ( "2", "3A", "3B", "4A", "4B", "HE", "HWOA", "HWSA", "P" ); - my %valid_types = map { $_ => 1 } @valid_types; - - for my $feature ( @{ $j->{features} } ) { +sub lookup_site_code_config { { + buffer => 200, # metres + url => "https://tilma.mysociety.org/mapserver/bucks", + srsname => "urn:ogc:def:crs:EPSG::27700", + typename => "Whole_Street", + property => "site_code", + accept_feature => sub { + my $feature = shift; + + # There are only certain features we care about, the rest can be ignored. + my @valid_types = ( "2", "3A", "3B", "4A", "4B", "HE", "HWOA", "HWSA", "P" ); + my %valid_types = map { $_ => 1 } @valid_types; my $type = $feature->{properties}->{feature_ty}; - next unless $valid_types{$type}; - - # We shouldn't receive anything aside from these two geometry types, but belt and braces. - next unless $feature->{geometry}->{type} eq 'MultiLineString' || $feature->{geometry}->{type} eq 'LineString'; - my @coordinates = @{ $feature->{geometry}->{coordinates} }; - if ( $feature->{geometry}->{type} eq 'MultiLineString') { - # The coordinates are stored as a list of lists, so flatten 'em out - @coordinates = map { @{ $_ } } @coordinates; - } - - # If any of this feature's points are closer than those we've seen so - # far then use the site_code from this feature. - for my $coords ( @coordinates ) { - my ($fx, $fy) = @$coords; - my $distance = $self->_distance($x, $y, $fx, $fy); - if ( !defined $nearest || $distance < $nearest ) { - $site_code = $feature->{properties}->{site_code}; - $nearest = $distance; - } - } + return $valid_types{$type}; } - - return $site_code; -} - - -=head2 _distance - -Returns the cartesian distance between two coordinates. -This is not a general-purpose distance function, it's intended for use with -fairly nearby coordinates in EPSG:27700 where a spheroid doesn't need to be -taken into account. - -=cut -sub _distance { - my ($self, $ax, $ay, $bx, $by) = @_; - return sqrt( (($ax - $bx) ** 2) + (($ay - $by) ** 2) ); -} +} } 1; diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index 816c5e315..a8146128e 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -13,7 +13,6 @@ use URI; use Digest::MD5 qw(md5_hex); use Carp; -use mySociety::MaPit; use mySociety::PostcodeUtil; =head1 The default cobrand @@ -237,6 +236,18 @@ sub base_url_for_report { return $self->base_url_with_lang; } +=item relative_url_for_report + +Returns the relative base url for a report (might be different in a two-tier +county, but normally blank). Report may be an object, or a hashref. + +=cut + +sub relative_url_for_report { + my ( $self, $report ) = @_; + return ""; +} + =item base_host Return the base host for the cobranded version of the site @@ -397,25 +408,6 @@ Return cobrand extra data for the problem sub cobrand_data_for_generic_problem { '' } -=item uri - -Given a URL ($_[1]), QUERY, EXTRA_DATA, return a URL with any extra params -needed appended to it. - -In the default case, we need to make sure zoom is always present if lat/lon -are, to stop OpenLayers defaulting to null/0. - -=cut - -sub uri { - my ( $self, $uri ) = @_; - $uri->query_param( zoom => $self->default_link_zoom ) - if $uri->query_param('lat') && !$uri->query_param('zoom'); - - return $uri; -} - - =item header_params Return any params to be added to responses @@ -445,6 +437,10 @@ sub reports_per_page { return FixMyStreet->config('ALL_REPORTS_PER_PAGE') || 100; } +sub report_age { + return '6 months'; +} + =item reports_ordering The order_by clause to use for reports on all reports page @@ -526,7 +522,7 @@ sub find_closest { my $problem = $data->{problem}; my $lat = $problem ? $problem->latitude : $data->{latitude}; my $lon = $problem ? $problem->longitude : $data->{longitude}; - my $j = $problem->geocode if $problem; + my $j = $problem ? $problem->geocode : undef; if (!$j) { $j = FixMyStreet::Geocode::Bing::reverse( $lat, $lon, @@ -713,12 +709,15 @@ sub available_permissions { report_edit => _("Edit reports"), report_edit_category => _("Edit report category"), # future use report_edit_priority => _("Edit report priority"), # future use + report_mark_private => _("View/Mark private reports"), report_inspect => _("Markup problem details"), report_instruct => _("Instruct contractors to fix problems"), # future use + report_prefill => _("Automatically populate report subject/detail"), planned_reports => _("Manage shortlist"), contribute_as_another_user => _("Create reports/updates on a user's behalf"), contribute_as_anonymous_user => _("Create reports/updates as anonymous user"), contribute_as_body => _("Create reports/updates as the council"), + default_to_body => _("Default to creating reports/updates as the council"), view_body_contribute_details => _("See user detail for reports created as the council"), # NB this permission is special in that it can be assigned to users @@ -761,6 +760,14 @@ used in emails). sub contact_name { FixMyStreet->config('CONTACT_NAME') } sub contact_email { FixMyStreet->config('CONTACT_EMAIL') } +=item abuse_reports_only + +Return true if only abuse reports should be allowed from the contact form. + +=cut + +sub abuse_reports_only { 0; } + =item email_host Return if we are the virtual host that sends email for this cobrand @@ -987,18 +994,14 @@ sub tweak_all_reports_map {} sub can_support_problems { return 0; } -=item default_map_zoom / default_link_zoom +=item default_map_zoom default_map_zoom is used when displaying a map overriding the default of max-4 or max-3 depending on population density. -default_link_zoom is used in links that contain a 'lat' and no -zoom, to stop e.g. OpenLayers defaulting to null/0. - =cut sub default_map_zoom { undef }; -sub default_link_zoom { 3 } sub users_can_hide { return 0; } @@ -1008,9 +1011,7 @@ Returns true if the show name checkbox should be ticked by default. =cut -sub default_show_name { - 1; -} +sub default_show_name { 0 } =item report_check_for_errors @@ -1029,7 +1030,7 @@ sub report_check_for_errors { ); } -sub report_sent_confirmation_email { 0; } +sub report_sent_confirmation_email { '' } =item never_confirm_reports @@ -1180,6 +1181,7 @@ Return true if an Open311 service attribute should be a hidden field. sub category_extra_hidden { my ($self, $meta) = @_; + return 1 if ($meta->{automated} || '') eq 'hidden_field'; return 0; } @@ -1243,4 +1245,12 @@ still be sent (because it wasn't disabled on the FixMyStreet cobrand). sub send_moderation_notifications { 1 } +=item privacy_policy_url + +The URL of the privacy policy to use on the report and update submissions forms. + +=cut + +sub privacy_policy_url { '/privacy' } + 1; diff --git a/perllib/FixMyStreet/Cobrand/FiksGataMi.pm b/perllib/FixMyStreet/Cobrand/FiksGataMi.pm index 4b95dfeaf..171faaa8b 100644 --- a/perllib/FixMyStreet/Cobrand/FiksGataMi.pm +++ b/perllib/FixMyStreet/Cobrand/FiksGataMi.pm @@ -135,6 +135,9 @@ sub council_rss_alert_options { } } + my $body_kommune = FixMyStreet::DB->resultset('Body')->for_areas($kommune->{id})->first; + my $body_fylke = FixMyStreet::DB->resultset('Body')->for_areas($fylke->{id})->first; + if ( $fylke->{id} == 3 ) { # Oslo my $short_name = $self->short_name($fylke); ( my $id_name = $short_name ) =~ tr/+/_/; @@ -142,7 +145,7 @@ sub council_rss_alert_options { push @options, { type => 'council', - id => sprintf( 'council:%s:%s', $fylke->{id}, $id_name ), + id => sprintf( 'council:%s:%s', $body_fylke->id, $id_name ), rss_text => sprintf( _('RSS feed of problems within %s'), $fylke->{name} ), text => sprintf( _('Problems within %s'), $fylke->{name} ), @@ -177,7 +180,7 @@ sub council_rss_alert_options { push @reported_to_options, { type => 'council', - id => sprintf( 'council:%s:%s', $kommune->{id}, $id_kommune_name ), + id => sprintf( 'council:%s:%s', $body_kommune->id, $id_kommune_name ), rss_text => sprintf( _('RSS feed of %s'), $kommune->{name} ), text => $kommune->{name}, @@ -185,11 +188,11 @@ sub council_rss_alert_options { }, { type => 'council', - id => sprintf( 'council:%s:%s', $fylke->{id}, $id_fylke_name ), + id => sprintf( 'council:%s:%s', $body_fylke->id, $id_fylke_name ), rss_text => sprintf( _('RSS feed of %s'), $fylke->{name} ), text => $fylke->{name}, - uri => $c->uri_for( '/rss/reports/', $short_fylke_name ), + uri => $c->uri_for( '/rss/reports', $short_fylke_name ), }; } diff --git a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm index 6c826ec01..fb454f495 100644 --- a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm +++ b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm @@ -8,6 +8,10 @@ use mySociety::Random; use constant COUNCIL_ID_BROMLEY => 2482; +sub on_map_default_status { return 'open'; } + +sub enable_category_groups { 1 } + # Special extra sub path_to_web_templates { my $self = shift; @@ -48,9 +52,6 @@ sub extra_contact_validation { my $self = shift; my $c = shift; - # Don't care about dest if reporting abuse - return () if $c->stash->{problem}; - my %errors; $c->stash->{dest} = $c->get_param('dest'); diff --git a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm index 29e840dfa..d1a1980a7 100644 --- a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm +++ b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm @@ -181,4 +181,15 @@ sub state_groups_inspect { ] } +sub always_view_body_contribute_details { + return 1; +} + +# Average responsiveness will only be calculated if a body +# has at least this many fixed reports. +# (Used in the Top 5 list in /reports) +sub body_responsiveness_threshold { + return 5; +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Greenwich.pm b/perllib/FixMyStreet/Cobrand/Greenwich.pm index 6ff30e83d..2aaa5d776 100644 --- a/perllib/FixMyStreet/Cobrand/Greenwich.pm +++ b/perllib/FixMyStreet/Cobrand/Greenwich.pm @@ -66,4 +66,13 @@ sub open311_config { $row->set_extra_fields( @$extra ); } +sub open311_contact_meta_override { + my ($self, $service, $contact, $meta) = @_; + + my %server_set = (easting => 1, northing => 1, closest_address => 1); + foreach (@$meta) { + $_->{automated} = 'server_set' if $server_set{$_->{code}}; + } +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Lewisham.pm b/perllib/FixMyStreet/Cobrand/Lewisham.pm new file mode 100644 index 000000000..325f6e833 --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/Lewisham.pm @@ -0,0 +1,15 @@ +package FixMyStreet::Cobrand::Lewisham; +use base 'FixMyStreet::Cobrand::UK'; + +use strict; +use warnings; + +sub council_area_id { 2492 } + +sub open311_post_update_skip { + my ($self) = @_; + return 1; +} + +1; + diff --git a/perllib/FixMyStreet/Cobrand/Lincolnshire.pm b/perllib/FixMyStreet/Cobrand/Lincolnshire.pm new file mode 100644 index 000000000..8d8ba3268 --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/Lincolnshire.pm @@ -0,0 +1,126 @@ +package FixMyStreet::Cobrand::Lincolnshire; +use parent 'FixMyStreet::Cobrand::UKCouncils'; + +use strict; +use warnings; + +use LWP::Simple; +use URI; +use Try::Tiny; +use JSON::MaybeXS; + +use Moo; +with 'FixMyStreet::Roles::ConfirmValidation'; + +sub council_area_id { return 2232; } +sub council_area { return 'Lincolnshire'; } +sub council_name { return 'Lincolnshire County Council'; } +sub council_url { return 'lincolnshire'; } +sub is_two_tier { 1 } + +sub enable_category_groups { 1 } +sub send_questionnaires { 0 } +sub report_sent_confirmation_email { 'external_id' } + +sub admin_user_domain { 'lincolnshire.gov.uk' } + +sub enter_postcode_text { + my ($self) = @_; + return 'Enter a Lincolnshire postcode, street name and area, or check an existing report number'; +} + + +sub base_url { + my $self = shift; + return $self->next::method() if FixMyStreet->config('STAGING_SITE'); + return 'https://fixmystreet.lincolnshire.gov.uk'; +} + +sub contact_email { + my $self = shift; + return join( '@', 'confirm_support', 'lincolnshire.gov.uk' ); +} + + +sub example_places { + return ( 'LN1 1YL', 'Orchard Street, Lincoln' ); +} + +sub disambiguate_location { + my $self = shift; + my $string = shift; + return { + %{ $self->SUPER::disambiguate_location() }, + town => 'Lincolnshire', + centre => '53.1128371079972,-0.237920757894981', + span => '0.976148231905086,1.17860658530345', + bounds => [ 52.6402179235688, -0.820651304784901, 53.6163661554738, 0.357955280518546 ], + }; +} + + +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $extra = $row->get_extra_fields; + push @$extra, + { name => 'report_url', + value => $h->{url} }, + { name => 'title', + value => $row->title }, + { name => 'description', + value => $row->detail }; + + # Reports made via FMS.com or the app probably won't have a site code + # value because we don't display the adopted highways layer on those + # frontends. Instead we'll look up the closest asset from the WFS + # service at the point we're sending the report over Open311. + if (!$row->get_extra_field_value('site_code')) { + if (my $site_code = $self->lookup_site_code($row)) { + push @$extra, + { name => 'site_code', + value => $site_code }; + } + } + + $row->set_extra_fields(@$extra); +} + +sub lookup_site_code_config { { + buffer => 200, # metres + url => "https://tilma.mysociety.org/mapserver/lincs", + srsname => "urn:ogc:def:crs:EPSG::27700", + typename => "NSG", + property => "Site_Code", + accept_feature => sub { 1 } +} } + + +sub categories_restriction { + my ($self, $rs) = @_; + # Lincolnshire is a two-tier council, but don't want to display + # all district-level categories on their cobrand - just a couple. + return $rs->search( { -or => [ + 'body.name' => "Lincolnshire County Council", + + # District categories: + 'me.category' => { -in => [ + 'Street nameplates', + 'Bench/cycle rack/litter bin/planter', + ] }, + ] } ); +} + +sub map_type { 'Lincolnshire' } + +sub pin_colour { + my ( $self, $p, $context ) = @_; + my $ext_status = $p->get_extra_metadata('external_status_code'); + return 'yellow' if $p->state eq 'confirmed' && $ext_status && $ext_status eq '0135'; + return 'red' if $p->state eq 'confirmed'; + return 'green' if $p->is_fixed || $p->is_closed; + return 'grey' if $p->state eq 'not responsible' || !$self->owns_problem( $p ); + return 'yellow'; +} + +1; diff --git a/perllib/FixMyStreet/Cobrand/Northamptonshire.pm b/perllib/FixMyStreet/Cobrand/Northamptonshire.pm new file mode 100644 index 000000000..683dc059c --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/Northamptonshire.pm @@ -0,0 +1,105 @@ +package FixMyStreet::Cobrand::Northamptonshire; +use parent 'FixMyStreet::Cobrand::Whitelabel'; + +use strict; +use warnings; + +use Moo; +with 'FixMyStreet::Roles::ConfirmValidation'; + +sub council_area_id { 2234 } +sub council_area { 'Northamptonshire' } +sub council_name { 'Northamptonshire County Council' } +sub council_url { 'northamptonshire' } + +sub example_places { ( 'NN1 1NS', "Bridge Street" ) } + +sub enter_postcode_text { 'Enter a Northamptonshire postcode, street name and area, or check an existing report number' } + +sub base_url { + my $self = shift; + return $self->next::method() if FixMyStreet->config('STAGING_SITE'); + return 'https://fixmystreet.northamptonshire.gov.uk'; +} + +sub disambiguate_location { + my $self = shift; + my $string = shift; + + return { + %{ $self->SUPER::disambiguate_location() }, + centre => '52.30769080650276,-0.8647071378799923', + bounds => [ 51.97726778979222, -1.332346116362747, 52.643600776698605, -0.3416080408721255 ], + }; +} + +sub categories_restriction { + my ($self, $rs) = @_; + return $rs->search( [ { 'body.name' => 'Northamptonshire County Council' } ], { join => { body => 'body_areas' } }); +} + +sub send_questionnaires { 0 } + +sub on_map_default_status { 'open' } + +sub report_sent_confirmation_email { 'id' } + +sub problems_on_map_restriction { + my ($self, $rs) = @_; + # Northamptonshire don't want to show district/borough reports + # on the site + return $self->problems_restriction($rs); +} + +sub contact_email { + my $self = shift; + return join( '@', 'highways', $self->council_url . '.gov.uk' ); +} + +sub privacy_policy_url { + 'https://www3.northamptonshire.gov.uk/councilservices/council-and-democracy/transparency/information-policies/privacy-notice/place/Pages/street-doctor.aspx' +} + +sub enable_category_groups { 1 } + +sub is_two_tier { 1 } + +sub get_geocoder { 'OSM' } + +sub map_type { 'OSM' } + +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $extra = $row->get_extra_fields; + + # remove the emergency category which is informational only + @$extra = grep { $_->{name} ne 'emergency' } @$extra; + + push @$extra, + { name => 'report_url', + value => $h->{url} }, + { name => 'title', + value => $row->title }, + { name => 'description', + value => $row->detail }, + { name => 'category', + value => $row->category }; + + $row->set_extra_fields(@$extra); + + $params->{multi_photos} = 1; +} + +# sending updates not part of initial phase +sub should_skip_sending_update { 1; } + +sub report_validation { + my ($self, $report, $errors) = @_; + + if ( length( $report->title ) > 120 ) { + $errors->{title} = sprintf( _('Summaries are limited to %s characters in length. Please shorten your summary'), 120 ); + } +} + +1; diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm index 479d9c43b..08482a0b3 100644 --- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm +++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm @@ -10,6 +10,28 @@ sub council_name { return 'Oxfordshire County Council'; } sub council_url { return 'oxfordshire'; } sub is_two_tier { return 1; } +sub report_validation { + my ($self, $report, $errors) = @_; + + if ( length( $report->detail ) > 1700 ) { + $errors->{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), 1700 ); + } + + if ( length( $report->name ) > 50 ) { + $errors->{name} = sprintf( 'Names are limited to %d characters in length.', 50 ); + } + + if ( length( $report->user->phone ) > 20 ) { + $errors->{phone} = sprintf( 'Phone numbers are limited to %s characters in length.', 20 ); + } + + if ( length( $report->user->email ) > 50 ) { + $errors->{username} = sprintf( 'Emails are limited to %s characters in length.', 50 ); + } + + return $errors; +} + sub is_council_with_case_management { # XXX Change this to return 1 when OCC FMSfC goes live. return FixMyStreet->config('STAGING_SITE'); @@ -51,7 +73,21 @@ sub default_map_zoom { return 3; } # let staff hide OCC reports sub users_can_hide { return 1; } -sub default_show_name { 0 } +sub lookup_by_ref_regex { + return qr/^\s*((?:ENQ)?\d+)\s*$/; +} + +sub lookup_by_ref { + my ($self, $ref) = @_; + + if ( $ref =~ /^ENQ/ ) { + my $len = length($ref); + my $filter = "%T18:customer_reference,T$len:$ref,%"; + return { 'extra' => { -like => $filter } }; + } + + return 0; +} =head2 problem_response_days @@ -130,10 +166,10 @@ sub pin_hover_title { sub state_groups_inspect { [ - [ _('New'), [ 'confirmed', 'investigating' ] ], - [ _('Scheduled'), [ 'action scheduled' ] ], - [ _('Fixed'), [ 'fixed - council' ] ], - [ _('Closed'), [ 'not responsible', 'duplicate', 'unable to fix' ] ], + [ 'New', [ 'confirmed', 'investigating' ] ], + [ 'Scheduled', [ 'action scheduled' ] ], + [ 'Fixed', [ 'fixed - council' ] ], + [ 'Closed', [ 'not responsible', 'duplicate', 'unable to fix' ] ], ] } @@ -142,22 +178,28 @@ sub open311_config { my $extra = $row->get_extra_fields; push @$extra, { name => 'external_id', value => $row->id }; + push @$extra, { name => 'northing', value => $h->{northing} }; + push @$extra, { name => 'easting', value => $h->{easting} }; if ($h->{closest_address}) { push @$extra, { name => 'closest_address', value => "$h->{closest_address}" } } - if ( $row->used_map || ( !$row->used_map && !$row->postcode ) ) { - push @$extra, { name => 'northing', value => $h->{northing} }; - push @$extra, { name => 'easting', value => $h->{easting} }; - } $row->set_extra_fields( @$extra ); $params->{extended_description} = 'oxfordshire'; } -sub open311_pre_send { - my ($self, $row, $open311) = @_; - $open311->endpoints( { requests => 'open311_service_request.cgi' } ); +sub open311_config_updates { + my ($self, $params) = @_; + $params->{use_customer_reference} = 1; +} + +sub should_skip_sending_update { + my ($self, $update ) = @_; + + # Oxfordshire stores the external id of the problem as a customer reference + # in metadata + return 1 if !$update->problem->get_extra_metadata('customer_reference'); } sub on_map_default_status { return 'open'; } @@ -234,6 +276,13 @@ sub available_permissions { my $perms = $self->next::method(); $perms->{Bodies}->{defect_type_edit} = "Add/edit defect types"; + delete $perms->{Problems}->{report_edit}; + delete $perms->{Problems}->{report_edit_category}; + delete $perms->{Problems}->{report_edit_priority}; + delete $perms->{Problems}->{report_inspect}; + delete $perms->{Problems}->{report_instruct}; + delete $perms->{Problems}->{planned_reports}; + return $perms; } diff --git a/perllib/FixMyStreet/Cobrand/Rutland.pm b/perllib/FixMyStreet/Cobrand/Rutland.pm index 6993b0964..af635ac59 100644 --- a/perllib/FixMyStreet/Cobrand/Rutland.pm +++ b/perllib/FixMyStreet/Cobrand/Rutland.pm @@ -9,6 +9,16 @@ sub council_area { return 'Rutland'; } sub council_name { return 'Rutland County Council'; } sub council_url { return 'rutland'; } +sub report_validation { + my ($self, $report, $errors) = @_; + + if ( length( $report->name ) > 40 ) { + $errors->{name} = sprintf( _('Names are limited to %d characters in length.'), 40 ); + } + + return $errors; +} + sub open311_config { my ($self, $row, $h, $params) = @_; diff --git a/perllib/FixMyStreet/Cobrand/UK.pm b/perllib/FixMyStreet/Cobrand/UK.pm index f99f29eb4..1c6ebe29a 100644 --- a/perllib/FixMyStreet/Cobrand/UK.pm +++ b/perllib/FixMyStreet/Cobrand/UK.pm @@ -190,9 +190,11 @@ sub council_rss_alert_options { my ( @options, @reported_to_options ); if ( $num_councils == 1 or $num_councils == 2 ) { my ($council, $ward); + my $body = FixMyStreet::DB->resultset('Body')->active->search({ name => { '!=' => 'TfL' } })->for_areas(keys %$all_areas)->first; foreach (values %$all_areas) { if ($councils{$_->{type}}) { $council = $_; + $council->{id} = $body->id; # Want to use body ID, not MapIt area ID $council->{short_name} = $self->short_name( $council ); ( $council->{id_name} = $council->{short_name} ) =~ tr/+/_/; } else { @@ -247,6 +249,9 @@ sub council_rss_alert_options { my $county_name = $county->{name}; my $c_ward_name = $c_ward->{name}; + my $body_dis = FixMyStreet::DB->resultset('Body')->active->for_areas($district->{id})->first; + my $body_cty = FixMyStreet::DB->resultset('Body')->active->for_areas($county->{id})->first; + push @options, { type => 'area', id => sprintf( 'area:%s:%s', $district->{id}, $district->{id_name} ), @@ -275,29 +280,32 @@ sub council_rss_alert_options { push @reported_to_options, { type => 'council', - id => sprintf( 'council:%s:%s', $district->{id}, $district->{id_name} ), + id => sprintf( 'council:%s:%s', $body_dis->id, $district->{id_name} ), text => sprintf( _('Reports sent to %s'), $district->{name} ), rss_text => sprintf( _('RSS feed of %s'), $district->{name}), uri => $c->uri_for( '/rss/reports/' . $district->{short_name} ), }, { type => 'ward', - id => sprintf( 'ward:%s:%s:%s:%s', $district->{id}, $d_ward->{id}, $district->{id_name}, $d_ward->{id_name} ), + id => sprintf( 'ward:%s:%s:%s:%s', $body_dis->id, $d_ward->{id}, $district->{id_name}, $d_ward->{id_name} ), rss_text => sprintf( _('RSS feed of %s, within %s ward'), $district->{name}, $d_ward->{name}), text => sprintf( _('Reports sent to %s, within %s ward'), $district->{name}, $d_ward->{name}), uri => $c->uri_for( '/rss/reports/' . $district->{short_name} . '/' . $d_ward->{short_name} ), - }, { + } + if $body_dis; + push @reported_to_options, { type => 'council', - id => sprintf( 'council:%s:%s', $county->{id}, $county->{id_name} ), + id => sprintf( 'council:%s:%s', $body_cty->id, $county->{id_name} ), text => sprintf( _('Reports sent to %s'), $county->{name} ), rss_text => sprintf( _('RSS feed of %s'), $county->{name}), uri => $c->uri_for( '/rss/reports/' . $county->{short_name} ), }, { type => 'ward', - id => sprintf( 'ward:%s:%s:%s:%s', $county->{id}, $c_ward->{id}, $county->{id_name}, $c_ward->{id_name} ), + id => sprintf( 'ward:%s:%s:%s:%s', $body_cty->id, $c_ward->{id}, $county->{id_name}, $c_ward->{id_name} ), rss_text => sprintf( _('RSS feed of %s, within %s ward'), $county->{name}, $c_ward->{name}), text => sprintf( _('Reports sent to %s, within %s ward'), $county->{name}, $c_ward->{name}), uri => $c->uri_for( '/rss/reports/' . $county->{short_name} . '/' . $c_ward->{short_name} ), - }; + } + if $body_cty; } else { throw Error::Simple('An area with three tiers of council? Impossible! '. join('|',keys %$all_areas)); @@ -323,13 +331,9 @@ sub report_check_for_errors { ); } - if ( $report->bodies_str && $report->detail ) { - # Custom character limit: - if ( $report->to_body_named('Bromley') && length($report->detail) > 1750 ) { - $errors{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), 1750 ); - } elsif ( $report->to_body_named('Oxfordshire') && length($report->detail) > 1700 ) { - $errors{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), 1700 ); - } + my $cobrand = $self->get_body_handler_for_problem($report); + if ( $cobrand->can('report_validation') ) { + $cobrand->report_validation( $report, \%errors ); } return %errors; @@ -351,7 +355,7 @@ sub get_body_handler_for_problem { my ($self, $row) = @_; my @bodies = values %{$row->bodies}; - my %areas = map { %{$_->areas} } @bodies; + my %areas = map { %{$_->areas} } grep { $_->name ne 'TfL' } @bodies; my $cobrand = FixMyStreet::Cobrand->body_handler(\%areas); return $cobrand if $cobrand; @@ -393,8 +397,7 @@ sub lookup_by_ref_regex { sub category_extra_hidden { my ($self, $meta) = @_; return 1 if $meta->{code} eq 'usrn' || $meta->{code} eq 'asset_id'; - return 1 if $meta->{automated} eq 'hidden_field'; - return 0; + return $self->SUPER::category_extra_hidden($meta); } 1; diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index 753aa2404..1beafef73 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -6,6 +6,10 @@ use warnings; use Carp; use URI::Escape; +use LWP::Simple; +use URI; +use Try::Tiny; +use JSON::MaybeXS; sub is_council { 1; @@ -38,10 +42,11 @@ sub restriction { return { cobrand => shift->moniker }; } -# UK cobrands assume that each MapIt area ID maps both ways with one body +# UK cobrands assume that each MapIt area ID maps both ways with one +# body. Except TfL. sub body { my $self = shift; - my $body = FixMyStreet::DB->resultset('Body')->for_areas($self->council_area_id)->first; + my $body = FixMyStreet::DB->resultset('Body')->for_areas($self->council_area_id)->search({ name => { '!=', 'TfL' } })->first; return $body; } @@ -175,31 +180,35 @@ sub owns_problem { } else { # Object @bodies = values %{$report->bodies}; } - my %areas = map { %{$_->areas} } @bodies; + # Want to ignore the TfL body that covers London councils + my %areas = map { %{$_->areas} } grep { $_->name ne 'TfL' } @bodies; return $areas{$self->council_area_id} ? 1 : undef; } -# If the council is two-tier then show pins for the other council as grey +# If the council is two-tier, or e.g. TfL reports, +# then show pins for the other council as grey sub pin_colour { my ( $self, $p, $context ) = @_; - return 'grey' if $self->is_two_tier && !$self->owns_problem( $p ); + return 'grey' if !$self->owns_problem( $p ); return $self->next::method($p, $context); } -# If we ever link to a county problem report, needs to be to main FixMyStreet +# If we ever link to a county problem report, or a TfL report, +# needs to be to main FixMyStreet sub base_url_for_report { my ( $self, $report ) = @_; - if ( $self->is_two_tier ) { - if ( $self->owns_problem( $report ) ) { - return $self->base_url; - } else { - return FixMyStreet->config('BASE_URL'); - } - } else { + if ( $self->owns_problem( $report ) ) { return $self->base_url; + } else { + return FixMyStreet->config('BASE_URL'); } } +sub relative_url_for_report { + my ( $self, $report ) = @_; + return $self->owns_problem($report) ? "" : FixMyStreet->config('BASE_URL'); +} + sub admin_allow_user { my ( $self, $user ) = @_; return 1 if $user->is_superuser; @@ -211,6 +220,7 @@ sub available_permissions { my $self = shift; my $perms = $self->next::method(); + $perms->{Problems}->{default_to_body} = "Default to creating reports/updates as " . $self->council_name; $perms->{Problems}->{contribute_as_body} = "Create reports/updates as " . $self->council_name; $perms->{Problems}->{view_body_contribute_details} = "See user detail for reports created as " . $self->council_name; $perms->{Users}->{user_assign_areas} = "Assign users to areas in " . $self->council_name; @@ -222,4 +232,118 @@ sub prefill_report_fields_for_inspector { 1 } sub social_auth_disabled { 1 } +=head2 lookup_site_code + +Reports made via FMS.com or the app probably won't have a site code +value (required for Confirm integrations) because we don't display +the adopted highways layer on those frontends. +Instead we'll look up the closest asset from the WFS +service at the point we're sending the report over Open311. + +NB this requires the cobrand to implement `lookup_site_code_config` - +see Buckinghamshire or Lincolnshire for an example. + + +=cut + +sub lookup_site_code { + my $self = shift; + my $row = shift; + my $buffer = shift; + + my $cfg = $self->lookup_site_code_config; + + $buffer ||= $cfg->{buffer}; # metres + my ($x, $y) = $row->local_coords; + my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer); + + my $uri = URI->new($cfg->{url}); + $uri->query_form( + REQUEST => "GetFeature", + SERVICE => "WFS", + SRSNAME => $cfg->{srsname}, + TYPENAME => $cfg->{typename}, + VERSION => "1.1.0", + outputformat => "geojson", + BBOX => "$w,$s,$e,$n" + ); + + my $response = get($uri); + + my $j = JSON->new->utf8->allow_nonref; + try { + $j = $j->decode($response); + } catch { + # There was either no asset found, or an error with the WFS + # call - in either case let's just proceed without the USRN. + return ''; + }; + + # We have a list of features, and we want to find the one closest to the + # report location. + my $site_code = ''; + my $nearest; + + for my $feature ( @{ $j->{features} } ) { + next unless $cfg->{accept_feature}($feature); + + # We shouldn't receive anything aside from these two geometry types, but belt and braces. + next unless $feature->{geometry}->{type} eq 'MultiLineString' || $feature->{geometry}->{type} eq 'LineString'; + + my @coordinates = @{ $feature->{geometry}->{coordinates} }; + if ( $feature->{geometry}->{type} eq 'MultiLineString') { + # The coordinates are stored as a list of lists, so flatten 'em out + @coordinates = map { @{ $_ } } @coordinates; + } + + # If any of this feature's points are closer than those we've seen so + # far then use the site_code from this feature. + for my $coords ( @coordinates ) { + my ($fx, $fy) = @$coords; + my $distance = $self->_distance($x, $y, $fx, $fy); + if ( !defined $nearest || $distance < $nearest ) { + $site_code = $feature->{properties}->{$cfg->{property}}; + $nearest = $distance; + } + } + } + + return $site_code; +} + +sub extra_contact_validation { + my $self = shift; + my $c = shift; + + # Don't care about dest unless reporting abuse + return () unless $c->stash->{problem}; + + my %errors; + + $c->stash->{dest} = $c->get_param('dest'); + + if (!$c->get_param('dest')) { + $errors{dest} = "Please enter a topic of your message"; + } elsif ( $c->get_param('dest') eq 'council' || $c->get_param('dest') eq 'update' ) { + $errors{not_for_us} = 1; + } + + return %errors; +} + + +=head2 _distance + +Returns the cartesian distance between two coordinates. +This is not a general-purpose distance function, it's intended for use with +fairly nearby coordinates in EPSG:27700 where a spheroid doesn't need to be +taken into account. + +=cut +sub _distance { + my ($self, $ax, $ay, $bx, $by) = @_; + return sqrt( (($ax - $bx) ** 2) + (($ay - $by) ** 2) ); +} + + 1; diff --git a/perllib/FixMyStreet/Cobrand/Warwickshire.pm b/perllib/FixMyStreet/Cobrand/Warwickshire.pm index 73f66f3da..c301450bc 100644 --- a/perllib/FixMyStreet/Cobrand/Warwickshire.pm +++ b/perllib/FixMyStreet/Cobrand/Warwickshire.pm @@ -34,4 +34,12 @@ sub contact_name { 'Warwickshire County Council (do not reply)'; } sub send_questionnaires { 0 } +sub open311_contact_meta_override { + my ($self, $service, $contact, $meta) = @_; + + $contact->set_extra_metadata( id_field => 'external_id'); + + @$meta = grep { $_->{code} ne 'closest_address' } @$meta; +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index b2a0e331d..9b6a3b9cb 100644 --- a/perllib/FixMyStreet/Cobrand/Zurich.pm +++ b/perllib/FixMyStreet/Cobrand/Zurich.pm @@ -7,6 +7,7 @@ use RABX; use List::Util qw(min); use Scalar::Util 'blessed'; use DateTime::Format::Pg; +use Try::Tiny; use strict; use warnings; @@ -87,8 +88,6 @@ sub example_places { sub languages { [ 'de-ch,Deutsch,de_CH' ] } sub language_override { 'de-ch' } -sub default_link_zoom { 6 } - sub prettify_dt { my $self = shift; my $dt = shift; @@ -236,10 +235,12 @@ my %public_holidays = map { $_ => 1 } ( '2019-01-01', '2019-01-02', '2019-04-19', '2019-04-22', '2019-04-08', '2019-05-01', '2019-05-30', '2019-06-10', '2019-08-01', '2019-09-09', '2019-12-25', '2019-12-26', + '2019-04-18', '2019-05-29', '2019-05-31', '2019-12-24', '2019-12-27', '2019-12-30', '2019-12-31', '2020-01-01', '2020-01-02', '2020-04-10', '2020-04-13', '2020-04-20', '2020-05-01', '2020-05-21', '2020-06-01', '2020-09-14', '2020-12-25', + '2020-05-20', '2020-05-22', '2020-12-24', '2020-12-28', '2020-12-29', '2020-12-30', '2020-12-31', '2021-01-01', '2021-04-02', '2021-04-05', '2021-04-19', '2021-05-13', '2021-05-24', @@ -315,6 +316,31 @@ sub get_or_check_overdue { return $self->overdue($problem); } +sub report_page_data { + my $self = shift; + my $c = $self->{c}; + + $c->stash->{page} = 'reports'; + $c->forward( 'stash_report_filter_status' ); + $c->forward( 'load_and_group_problems' ); + $c->stash->{body} = { id => 0 }; # So template can fetch the list + + if ($c->get_param('ajax')) { + $c->detach('ajax', [ 'reports/_problem-list.html' ]); + } + + my $pins = $c->stash->{pins}; + FixMyStreet::Map::display_map( + $c, + latitude => @$pins ? $pins->[0]{latitude} : 0, + longitude => @$pins ? $pins->[0]{longitude} : 0, + area => 274456, + pins => $pins, + any_zoom => 1, + ); + return 1; +} + =head1 C<set_problem_state> If the state has changed, sets the state and calls C::Admin's C<log_edit> action. @@ -625,7 +651,7 @@ sub admin_report_edit { && $new_cat && $new_cat ne $problem->category ) { - my $cat = $c->model('DB::Contact')->search({ category => $c->get_param('category') } )->first; + my $cat = $c->model('DB::Contact')->not_deleted->search({ category => $c->get_param('category') } )->first; my $old_cat = $problem->category; $problem->category( $new_cat ); $problem->external_body( undef ); @@ -1037,7 +1063,7 @@ sub munge_sendreport_params { } sub admin_fetch_all_bodies { - my ( $self, @bodies ) = @_; + my ( $self ) = @_; sub tree_sort { my ( $level, $id, $sorted, $out ) = @_; @@ -1047,26 +1073,30 @@ sub admin_fetch_all_bodies { if ( $level == 0 ) { @sorted = sort { # Want Zurich itself at the top. - return -1 if $sorted->{$a->id}; - return 1 if $sorted->{$b->id}; + return -1 if $sorted->{$a->{id}}; + return 1 if $sorted->{$b->{id}}; # Otherwise, by name - strcoll($a->name, $b->name) + strcoll($a->{name}, $b->{name}) } @$array; } else { - @sorted = sort { strcoll($a->name, $b->name) } @$array; + @sorted = sort { strcoll($a->{name}, $b->{name}) } @$array; } foreach ( @sorted ) { - $_->api_key( $level ); # Misuse + $_->{indent_level} = $level; push @$out, $_; - if ($sorted->{$_->id}) { - tree_sort( $level+1, $_->id, $sorted, $out ); + if ($sorted->{$_->{id}}) { + tree_sort( $level+1, $_->{id}, $sorted, $out ); } } } + my @bodies = FixMyStreet::DB->resultset('Body')->search(undef, { + columns => [ "id", "name", "deleted", "parent", "endpoint" ], + })->translated->with_children_count->all_sorted; + my %sorted; foreach (@bodies) { - my $p = $_->parent ? $_->parent->id : 0; + my $p = $_->{parent} ? $_->{parent}{id} : 0; push @{$sorted{$p}}, $_; } @@ -1172,95 +1202,100 @@ sub admin_stats { sub export_as_csv { my ($self, $c, $params) = @_; - $c->model('DB')->schema->storage->sql_maker->quote_char('"'); - my $csv = $c->stash->{csv} = { - problems => $c->model('DB::Problem')->search_rs( - $params, - { - join => ['admin_log_entries', 'user'], - distinct => 1, - columns => [ - 'id', 'created', - 'latitude', 'longitude', - 'cobrand', 'category', - 'state', 'user_id', - 'external_body', - 'title', 'detail', - 'photo', - 'whensent', 'lastupdate', - 'service', - 'extra', - { sum_time_spent => { sum => 'admin_log_entries.time_spent' } }, - 'name', 'user.id', 'user.email', 'user.phone', 'user.name', - ] - } - ), - headers => [ - 'Report ID', 'Created', 'Sent to Agency', 'Last Updated', - 'E', 'N', 'Category', 'Status', 'Closure Status', - 'UserID', 'User email', 'User phone', 'User name', - 'External Body', 'Time Spent', 'Title', 'Detail', - 'Media URL', 'Interface Used', 'Council Response', - 'Strasse', 'Mast-Nr.', 'Haus-Nr.', 'Hydranten-Nr.', - ], - columns => [ - 'id', 'created', 'whensent',' lastupdate', 'local_coords_x', - 'local_coords_y', 'category', 'state', 'closure_status', - 'user_id', 'user_email', 'user_phone', 'user_name', - 'body_name', 'sum_time_spent', 'title', 'detail', - 'media_url', 'service', 'public_response', - 'strasse', 'mast_nr',' haus_nr', 'hydranten_nr', - ], - extra_data => sub { - my $report = shift; - - my $body_name = ""; - if ( my $external_body = $report->body($c) ) { - $body_name = $external_body->name || '[Unknown body]'; - } + try { + $c->model('DB')->schema->storage->sql_maker->quote_char('"'); + my $csv = $c->stash->{csv} = { + objects => $c->model('DB::Problem')->search_rs( + $params, + { + join => ['admin_log_entries', 'user'], + distinct => 1, + columns => [ + 'id', 'created', + 'latitude', 'longitude', + 'cobrand', 'category', + 'state', 'user_id', + 'external_body', + 'title', 'detail', + 'photo', + 'whensent', 'lastupdate', + 'service', + 'extra', + { sum_time_spent => { sum => 'admin_log_entries.time_spent' } }, + 'name', 'user.id', 'user.email', 'user.phone', 'user.name', + ] + } + ), + headers => [ + 'Report ID', 'Created', 'Sent to Agency', 'Last Updated', + 'E', 'N', 'Category', 'Status', 'Closure Status', + 'UserID', 'User email', 'User phone', 'User name', + 'External Body', 'Time Spent', 'Title', 'Detail', + 'Media URL', 'Interface Used', 'Council Response', + 'Strasse', 'Mast-Nr.', 'Haus-Nr.', 'Hydranten-Nr.', + ], + columns => [ + 'id', 'created', 'whensent',' lastupdate', 'local_coords_x', + 'local_coords_y', 'category', 'state', 'closure_status', + 'user_id', 'user_email', 'user_phone', 'user_name', + 'body_name', 'sum_time_spent', 'title', 'detail', + 'media_url', 'service', 'public_response', + 'strasse', 'mast_nr',' haus_nr', 'hydranten_nr', + ], + extra_data => sub { + my $report = shift; + + my $body_name = ""; + if ( my $external_body = $report->body($c) ) { + $body_name = $external_body->name || '[Unknown body]'; + } - my $detail = $report->detail; - my $public_response = $report->get_extra_metadata('public_response') || ''; - my $metas = $report->get_extra_fields(); - my %extras; - foreach my $field (@$metas) { - $extras{$field->{name}} = $field->{value}; - } + my $detail = $report->detail; + my $public_response = $report->get_extra_metadata('public_response') || ''; + my $metas = $report->get_extra_fields(); + my %extras; + foreach my $field (@$metas) { + $extras{$field->{name}} = $field->{value}; + } - # replace newlines with HTML <br/> element - $detail =~ s{\r?\n}{ <br/> }g; - $public_response =~ s{\r?\n}{ <br/> }g if $public_response; - - # Assemble photo URL, if report has a photo - my $photo_to_display = $c->cobrand->allow_photo_display($report); - my $media_url = (@{$report->photos} && $photo_to_display) - ? $c->cobrand->base_url . $report->photos->[$photo_to_display-1]->{url} - : ''; - - return { - whensent => $report->whensent, - lastupdate => $report->lastupdate, - user_id => $report->user_id, - user_email => $report->user->email || '', - user_phone => $report->user->phone || '', - user_name => $report->name, - closure_status => $report->get_extra_metadata('closure_status') || '', - body_name => $body_name, - sum_time_spent => $report->get_column('sum_time_spent') || 0, - detail => $detail, - media_url => $media_url, - service => $report->service || 'Web interface', - public_response => $public_response, - strasse => $extras{'strasse'} || '', - mast_nr => $extras{'mast_nr'} || '', - haus_nr => $extras{'haus_nr'} || '', - hydranten_nr => $extras{'hydranten_nr'} || '' - }; - }, - filename => 'stats', + # replace newlines with HTML <br/> element + $detail =~ s{\r?\n}{ <br/> }g; + $public_response =~ s{\r?\n}{ <br/> }g if $public_response; + + # Assemble photo URL, if report has a photo + my $photo_to_display = $c->cobrand->allow_photo_display($report); + my $media_url = (@{$report->photos} && $photo_to_display) + ? $c->cobrand->base_url . $report->photos->[$photo_to_display-1]->{url} + : ''; + + return { + whensent => $report->whensent, + lastupdate => $report->lastupdate, + user_id => $report->user_id, + user_email => $report->user->email || '', + user_phone => $report->user->phone || '', + user_name => $report->name, + closure_status => $report->get_extra_metadata('closure_status') || '', + body_name => $body_name, + sum_time_spent => $report->get_column('sum_time_spent') || 0, + detail => $detail, + media_url => $media_url, + service => $report->service || 'Web interface', + public_response => $public_response, + strasse => $extras{'strasse'} || '', + mast_nr => $extras{'mast_nr'} || '', + haus_nr => $extras{'haus_nr'} || '', + hydranten_nr => $extras{'hydranten_nr'} || '' + }; + }, + filename => 'stats', + }; + $c->forward('/dashboard/generate_csv'); + } catch { + die $_; + } finally { + $c->model('DB')->schema->storage->sql_maker->quote_char(''); }; - $c->forward('/dashboard/generate_csv'); - $c->model('DB')->schema->storage->sql_maker->quote_char(''); } sub problem_confirm_email_extras { diff --git a/perllib/FixMyStreet/DB/Factories.pm b/perllib/FixMyStreet/DB/Factories.pm index 56cff280b..5af9ed38f 100644 --- a/perllib/FixMyStreet/DB/Factories.pm +++ b/perllib/FixMyStreet/DB/Factories.pm @@ -94,9 +94,25 @@ sub data { my $self = shift; my %titles = ( + 'Abandoned vehicles' => ['Car on pavement, has been there for months', 'Silver car outside house, never used'], + 'Bus stops' => ['Bus stop sign wonky', 'Information board broken'], + 'Dog fouling' => ['Bad dog fouling in alley way', 'Inconsiderate dog owner' ], + 'Flyposting' => ['Fence by road covered in posters', 'Under the bridge is a poster haven'], + 'Flytipping' => ['Flytipping on country lane', 'Ten bags of rubbish'], + 'Footpath/bridleway away from road' => ['Vehicle blocking footpath'], + 'Graffiti' => ['Graffiti', 'Graffiti', 'Offensive graffiti', 'Graffiti on the bridge', 'Remove graffiti'], + 'Parks/landscapes' => ['Full litter bins', 'Allotment gate needs repair'], + 'Pavements' => ['Hedge encroaching pavement', 'Many cracked slabs on street corner'], 'Potholes' => ['Deep pothole', 'Small pothole', 'Pothole in cycle lane', 'Pothole on busy pavement', 'Large pothole', 'Sinking manhole'], + 'Public toilets' => ['Door will not open'], + 'Roads/highways' => ['Restricted sight line by zig-zag lines', 'Missing lane markings'], + 'Road traffic signs' => ['Bent sign', 'Zebra crossing', 'Bollard missing'], + 'Rubbish (refuse and recycling)' => ['Missing bin', 'Bags left uncollected'], + 'Street cleaning' => ['Two abandoned trollies', 'Yet more litter'], 'Street lighting' => ['Faulty light', 'Street light not working', 'Lights out in tunnel', 'Light not coming on', 'Light not going off'], - 'Graffiti' => ['Graffiti', 'Graffiti', 'Offensive graffiti', 'Graffiti on the bridge', 'Remove graffiti'], + 'Street nameplates' => ['Broken nameplate', 'Missing nameplate'], + 'Traffic lights' => ['Out of sync lights', 'Always on green', 'Broken light'], + 'Trees' => ['Young tree damaged', 'Tree looks dangerous in wind'], 'Other' => ['Loose drain cover', 'Flytipping on country lane', 'Vehicle blocking footpath', 'Hedge encroaching pavement', 'Full litter bins'], ); my %photos = ( @@ -171,8 +187,8 @@ sub create_problem { $params->{latitude} += rand(2 * $inaccurate_km) - $inaccurate_km; $params->{longitude} += rand(3 * $inaccurate_km) - 1.5 * $inaccurate_km, - $params->{title} = $titles->[$rand]; - $params->{detail} = $descs->[$rand]; + $params->{title} ||= $titles->[$rand]; + $params->{detail} ||= $descs->[$rand] || 'Please deal with this issue, thank you.'; $params->{photo_id} = $photo; $params->{confirmed} = DateTime::Format::Pg->format_datetime($params->{confirmed}); return $self->create($params); @@ -183,7 +199,7 @@ sub create_problem { package FixMyStreet::DB::Factory::Body; use parent -norequire, "FixMyStreet::DB::Factory::Base"; -use mySociety::MaPit; +use FixMyStreet::MapIt; __PACKAGE__->resultset(FixMyStreet::DB->resultset("Body")); @@ -192,7 +208,7 @@ __PACKAGE__->exclude(['area_id', 'categories']); __PACKAGE__->fields({ name => __PACKAGE__->callback(sub { my $area_id = shift->get('area_id'); - my $area = mySociety::MaPit::call('area', $area_id); + my $area = FixMyStreet::MapIt::call('area', $area_id); $area->{name}; }), body_areas => __PACKAGE__->callback(sub { @@ -212,7 +228,7 @@ sub key_field { 'id' } package FixMyStreet::DB::Factory::Contact; -use parent "DBIx::Class::Factory"; +use parent -norequire, "FixMyStreet::DB::Factory::Base"; __PACKAGE__->resultset(FixMyStreet::DB->resultset("Contact")); @@ -224,8 +240,8 @@ __PACKAGE__->fields({ category => 'Other', email => __PACKAGE__->callback(sub { my $category = shift->get('category'); - (my $email = lc $_) =~ s/ /-/g; - lc $category . '@example.org'; + (my $email = lc $category) =~ s/ /-/g; + $email . '@example.org'; }), state => 'confirmed', editor => 'Factory', @@ -233,6 +249,8 @@ __PACKAGE__->fields({ note => 'Created by factory', }); +sub key_field { 'id' } + ####################### package FixMyStreet::DB::Factory::ResponseTemplate; diff --git a/perllib/FixMyStreet/DB/RABXColumn.pm b/perllib/FixMyStreet/DB/RABXColumn.pm index 5f1583018..d14b48dc8 100644 --- a/perllib/FixMyStreet/DB/RABXColumn.pm +++ b/perllib/FixMyStreet/DB/RABXColumn.pm @@ -59,7 +59,6 @@ sub rabx_column { my $self = shift; my $ser = shift; return undef unless defined $ser; - utf8::encode($ser) if utf8::is_utf8($ser); my $h = new IO::String($ser); return RABX::wire_rd($h); }, @@ -78,18 +77,23 @@ sub rabx_column { $RABX_COLUMNS{ _get_class_identifier($class) }{$col} = 1; } +# The underlying column should always be UTF-8 encoded bytes. +sub get_column { + my ($self, $col) = @_; + my $res = $self->next::method ($col); + utf8::encode($res) if $RABX_COLUMNS{_get_class_identifier($self)}{$col} && utf8::is_utf8($res); + return $res; +} sub set_filtered_column { my ($self, $col, $val) = @_; - my $class = ref $self; - # because filtered objects may be expensive to marshall for storage there # is a cache that attempts to detect if they have changed or not. For us # this cache breaks things and our marshalling is cheap, so clear it when # trying set a column. delete $self->{_filtered_column}{$col} - if $RABX_COLUMNS{ _get_class_identifier($class) }{$col}; + if $RABX_COLUMNS{ _get_class_identifier($self) }{$col}; return $self->next::method($col, $val); } diff --git a/perllib/FixMyStreet/DB/Result/Abuse.pm b/perllib/FixMyStreet/DB/Result/Abuse.pm index e8e554afa..7818eb743 100644 --- a/perllib/FixMyStreet/DB/Result/Abuse.pm +++ b/perllib/FixMyStreet/DB/Result/Abuse.pm @@ -8,14 +8,18 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("abuse"); __PACKAGE__->add_columns("email", { data_type => "text", is_nullable => 0 }); __PACKAGE__->set_primary_key("email"); -# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:PnQhGMx+ktK++3gWOMJBpQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:6XdWpymMMUEC4WT9Yh0RLw # You can replace this text with custom code or comments, and it will be preserved on regeneration 1; diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm index 1c9bd3a63..221690405 100644 --- a/perllib/FixMyStreet/DB/Result/AdminLog.pm +++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("admin_log"); __PACKAGE__->add_columns( "id", @@ -54,7 +58,7 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-07-20 14:38:36 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:y2xZ4BDv7H+f4vbIZyNflw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BLPP1KitphuY56ptaXhzgg 1; diff --git a/perllib/FixMyStreet/DB/Result/Alert.pm b/perllib/FixMyStreet/DB/Result/Alert.pm index 2a52a7bca..8979fa338 100644 --- a/perllib/FixMyStreet/DB/Result/Alert.pm +++ b/perllib/FixMyStreet/DB/Result/Alert.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("alert"); __PACKAGE__->add_columns( "id", @@ -65,8 +69,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:5RNyB430T8PqtFlmGV/MUg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:pWmsXAFvvjr4x1Q3Zsu4Cg # You can replace this text with custom code or comments, and it will be preserved on regeneration @@ -75,17 +79,6 @@ use namespace::clean -except => [ 'meta' ]; with 'FixMyStreet::Roles::Abuser'; -my $stz = sub { - my ( $orig, $self ) = ( shift, shift ); - my $s = $self->$orig(@_); - return $s unless $s && UNIVERSAL::isa($s, "DateTime"); - FixMyStreet->set_time_zone($s); - return $s; -}; - -around whensubscribed => $stz; -around whendisabled => $stz; - =head2 confirm $alert->confirm(); diff --git a/perllib/FixMyStreet/DB/Result/AlertSent.pm b/perllib/FixMyStreet/DB/Result/AlertSent.pm index 83043a33b..d4e669f7f 100644 --- a/perllib/FixMyStreet/DB/Result/AlertSent.pm +++ b/perllib/FixMyStreet/DB/Result/AlertSent.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("alert_sent"); __PACKAGE__->add_columns( "alert_id", @@ -31,8 +35,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:/+Vodu8VJxJ0EY9P3Qjjjw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:xriosaSCkOo/REOG1OxdQA # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/AlertType.pm b/perllib/FixMyStreet/DB/Result/AlertType.pm index 3aa9677e0..3d9603008 100644 --- a/perllib/FixMyStreet/DB/Result/AlertType.pm +++ b/perllib/FixMyStreet/DB/Result/AlertType.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("alert_type"); __PACKAGE__->add_columns( "ref", @@ -47,8 +51,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:KDBYzNEAM5lPvZjb9cv22g +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:7JyCGS/rEvL1++p520749w # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm index 74a38f225..663181746 100644 --- a/perllib/FixMyStreet/DB/Result/Body.pm +++ b/perllib/FixMyStreet/DB/Result/Body.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("body"); __PACKAGE__->add_columns( "id", @@ -18,6 +22,12 @@ __PACKAGE__->add_columns( is_nullable => 0, sequence => "body_id_seq", }, + "name", + { data_type => "text", is_nullable => 0 }, + "external_url", + { data_type => "text", is_nullable => 1 }, + "parent", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "endpoint", { data_type => "text", is_nullable => 1 }, "jurisdiction", @@ -36,20 +46,14 @@ __PACKAGE__->add_columns( { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "send_extended_statuses", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, - "name", - { data_type => "text", is_nullable => 0 }, - "parent", - { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, - "deleted", - { data_type => "boolean", default_value => \"false", is_nullable => 0 }, - "external_url", - { data_type => "text", is_nullable => 1 }, "fetch_problems", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "blank_updates_permitted", - { data_type => "boolean", default_value => \"false", is_nullable => 1 }, + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "convert_latlong", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "deleted", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "extra", { data_type => "text", is_nullable => 1 }, ); @@ -126,22 +130,30 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2018-04-05 14:29:33 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HV8IM2C1ErrpvXoRTZ1B1Q +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:8CuxbffDaYS7TFlgff1nEg __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); use Moo; use namespace::clean; +use FixMyStreet::MapIt; with 'FixMyStreet::Roles::Translatable', 'FixMyStreet::Roles::Extra'; +sub _url { + my ( $obj, $cobrand, $args ) = @_; + my $uri = URI->new('/reports/' . $cobrand->short_name($obj)); + $uri->query_form($args) if $args; + return $uri; +} + sub url { my ( $self, $c, $args ) = @_; - # XXX $areas_info was used here for Norway parent - needs body parents, I guess - return $c->uri_for( '/reports/' . $c->cobrand->short_name( $self ), $args || {} ); + my $cobrand = $self->result_source->schema->cobrand; + return _url($self, $cobrand, $args); } __PACKAGE__->might_have( @@ -174,7 +186,8 @@ sub first_area_children { return unless $body_area; my $cobrand = $self->result_source->schema->cobrand; - my $children = mySociety::MaPit::call('area/children', $body_area->area_id, + + my $children = FixMyStreet::MapIt::call('area/children', $body_area->area_id, type => $cobrand->area_types_children, ); @@ -197,7 +210,8 @@ sub get_cobrand_handler { } sub calculate_average { - my ($self) = @_; + my ($self, $threshold) = @_; + $threshold ||= 0; my $substmt = "select min(id) from comment where me.problem_id=comment.problem_id and (problem_state in ('fixed', 'fixed - council', 'fixed - user') or mark_fixed)"; my $subquery = FixMyStreet::DB->resultset('Comment')->to_body($self)->search({ @@ -207,6 +221,7 @@ sub calculate_average { ], 'me.id' => \"= ($substmt)", 'me.state' => 'confirmed', + 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ], }, { select => [ { extract => "epoch from me.confirmed-problem.confirmed", -as => 'time' }, @@ -217,12 +232,15 @@ sub calculate_average { join => 'problem' })->as_subselect_rs; - my $avg = $subquery->search({ + my $result = $subquery->search({ }, { - select => [ { avg => "time" } ], - as => [ qw/avg/ ], - })->first->get_column('avg'); - return $avg; + select => [ { avg => "time" }, { count => "time" } ], + as => [ qw/avg count/ ], + })->first; + my $avg = $result->get_column('avg'); + my $count = $result->get_column('count'); + + return $count >= $threshold ? $avg : undef; } 1; diff --git a/perllib/FixMyStreet/DB/Result/BodyArea.pm b/perllib/FixMyStreet/DB/Result/BodyArea.pm index 4447777dc..7f0956c7d 100644 --- a/perllib/FixMyStreet/DB/Result/BodyArea.pm +++ b/perllib/FixMyStreet/DB/Result/BodyArea.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("body_areas"); __PACKAGE__->add_columns( "body_id", @@ -25,8 +29,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:+hzie6kHleUBoEt199c/nQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VPs3e9McGNO+Dd7C4pApxw __PACKAGE__->set_primary_key(__PACKAGE__->columns); diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm index 8a4dbe475..5d0253ef4 100644 --- a/perllib/FixMyStreet/DB/Result/Comment.pm +++ b/perllib/FixMyStreet/DB/Result/Comment.pm @@ -6,10 +6,13 @@ package FixMyStreet::DB::Result::Comment; use strict; use warnings; -use FixMyStreet::Template; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("comment"); __PACKAGE__->add_columns( "id", @@ -70,8 +73,8 @@ __PACKAGE__->add_columns( { data_type => "timestamp", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); -__PACKAGE__->might_have( - "moderation_original_data", +__PACKAGE__->has_many( + "moderation_original_datas", "FixMyStreet::DB::Result::ModerationOriginalData", { "foreign.comment_id" => "self.id" }, { cascade_copy => 0, cascade_delete => 0 }, @@ -90,8 +93,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZR+YNA1Jej3s+8mr52iq6Q +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:CozqNY621I8G7kUPXi5RoQ # __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); @@ -99,21 +102,32 @@ __PACKAGE__->rabx_column('extra'); use Moo; use namespace::clean -except => [ 'meta' ]; +use FixMyStreet::Template; with 'FixMyStreet::Roles::Abuser', 'FixMyStreet::Roles::Extra', + 'FixMyStreet::Roles::Moderation', 'FixMyStreet::Roles::PhotoSet'; -my $stz = sub { - my ( $orig, $self ) = ( shift, shift ); - my $s = $self->$orig(@_); - return $s unless $s && UNIVERSAL::isa($s, "DateTime"); - FixMyStreet->set_time_zone($s); - return $s; -}; +=head2 get_cobrand_logged + +Get a cobrand object for the cobrand the update was made on. + +e.g. if an update was logged at www.fixmystreet.com, this will be a +FixMyStreet::Cobrand::FixMyStreet object. + +=cut + +has get_cobrand_logged => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $cobrand_class = FixMyStreet::Cobrand->get_class_for_moniker( $self->cobrand ); + return $cobrand_class->new; + }, +); -around created => $stz; -around confirmed => $stz; # You can replace this text with custom code or comments, and it will be preserved on regeneration @@ -156,17 +170,6 @@ sub url { return "/report/" . $self->problem_id . '#update_' . $self->id; } -=head2 latest_moderation_log_entry - -Return most recent ModerationLog object - -=cut - -sub latest_moderation_log_entry { - my $self = shift; - return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => { -desc => 'id' } })->first; -} - __PACKAGE__->has_many( "admin_log_entries", "FixMyStreet::DB::Result::AdminLog", @@ -177,24 +180,24 @@ __PACKAGE__->has_many( } ); -# we already had the `moderation_original_data` rel above, as inferred by -# Schema::Loader, but that doesn't know about the problem_id mapping, so we now -# (slightly hackishly) redefine here: -# -# we also add cascade_delete, though this seems to be insufficient. -# -# TODO: should add FK on moderation_original_data field for this, to get S::L to -# pick up without hacks. - +# This will return the oldest moderation_original_data, if any. +# The plural can be used to return all entries. __PACKAGE__->might_have( "moderation_original_data", "FixMyStreet::DB::Result::ModerationOriginalData", { "foreign.comment_id" => "self.id", "foreign.problem_id" => "self.problem_id", }, - { cascade_copy => 0, cascade_delete => 1 }, + { order_by => 'id', + rows => 1, + cascade_copy => 0, cascade_delete => 1 }, ); +sub moderation_filter { + my $self = shift; + { problem_id => $self->problem_id }; +} + =head2 meta_line Returns a string to be used on a report update, describing some of the metadata @@ -228,7 +231,9 @@ sub meta_line { $body = "$body <img src='/cobrands/greenwich/favicon.png' alt=''>"; } } - my $can_view_contribute = $c->user_exists && $c->user->has_permission_to('view_body_contribute_details', $self->problem->bodies_str_ids); + my $cobrand_always_view_body_user = $c->cobrand->call_hook("always_view_body_contribute_details"); + my $can_view_contribute = $cobrand_always_view_body_user || + ($c->user_exists && $c->user->has_permission_to('view_body_contribute_details', $self->problem->bodies_str_ids)); if ($self->text) { if ($can_view_contribute) { $meta = sprintf( _( 'Posted by <strong>%s</strong> (%s) at %s' ), $body, $user_name, Utils::prettify_dt( $self->confirmed ) ); @@ -253,24 +258,22 @@ sub meta_line { return $meta; }; +sub problem_state_processed { + my $self = shift; + return 'fixed - user' if $self->mark_fixed; + return 'confirmed' if $self->mark_open; + return $self->problem_state; +} + sub problem_state_display { my ( $self, $c ) = @_; - my $update_state = ''; - my $cobrand = $c->cobrand->moniker; - - if ($self->mark_fixed) { - return FixMyStreet::DB->resultset("State")->display('fixed', 1); - } elsif ($self->mark_open) { - return FixMyStreet::DB->resultset("State")->display('confirmed', 1); - } elsif ($self->problem_state) { - my $state = $self->problem_state; - my $cobrand_name = $cobrand; - $cobrand_name = 'bromley' if $self->problem->to_body_named('Bromley'); - $update_state = FixMyStreet::DB->resultset("State")->display($state, 1, $cobrand_name); - } + my $state = $self->problem_state_processed; + return '' unless $state; - return $update_state; + my $cobrand_name = $c->cobrand->moniker; + $cobrand_name = 'bromley' if $self->problem->to_body_named('Bromley'); + return FixMyStreet::DB->resultset("State")->display($state, 1, $cobrand_name); } sub is_latest { @@ -298,4 +301,27 @@ sub hide { return $ret; } +sub as_hashref { + my ($self, $c, $cols) = @_; + + my $out = { + id => $self->id, + problem_id => $self->problem_id, + text => $self->text, + state => $self->state, + created => $self->created, + }; + + $out->{problem_state} = $self->problem_state_processed; + + $out->{photos} = [ map { $_->{url} } @{$self->photos} ] if !$cols || $cols->{photos}; + + if ($self->confirmed) { + $out->{confirmed} = $self->confirmed if !$cols || $cols->{confirmed}; + $out->{confirmed_pp} = $c->cobrand->prettify_dt( $self->confirmed ) if !$cols || $cols->{confirmed_pp}; + } + + return $out; +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/Contact.pm b/perllib/FixMyStreet/DB/Result/Contact.pm index c544f084a..17620f279 100644 --- a/perllib/FixMyStreet/DB/Result/Contact.pm +++ b/perllib/FixMyStreet/DB/Result/Contact.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("contacts"); __PACKAGE__->add_columns( "id", @@ -24,6 +28,8 @@ __PACKAGE__->add_columns( { data_type => "text", default_value => "Other", is_nullable => 0 }, "email", { data_type => "text", is_nullable => 0 }, + "state", + { data_type => "text", is_nullable => 0 }, "editor", { data_type => "text", is_nullable => 0 }, "whenedited", @@ -42,8 +48,6 @@ __PACKAGE__->add_columns( { data_type => "text", default_value => "", is_nullable => 1 }, "send_method", { data_type => "text", is_nullable => 1 }, - "state", - { data_type => "text", is_nullable => 0 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->add_unique_constraint("contacts_body_id_category_idx", ["body_id", "category"]); @@ -73,8 +77,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-07-08 20:45:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:t/VtPP11R8bbqPZdEVXffw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:f7XjQj4iABikbR4EZrjL3g __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); @@ -94,14 +98,9 @@ sub category_display { $self->translate_column('category'); } -sub get_metadata_for_input { +sub get_metadata_for_editing { my $self = shift; - my $id_field = $self->id_field; my @metadata = @{$self->get_extra_fields}; - # First, ones we always want to ignore (hard-coded, old system) - @metadata = grep { $_->{code} !~ /^(easting|northing|closest_address|$id_field)$/ } @metadata; - # Also ignore any we have with a 'server_set' automated attribute - @metadata = grep { !$_->{automated} || $_->{automated} ne 'server_set' } @metadata; # Just in case the extra data is in an old parsed format foreach (@metadata) { @@ -112,6 +111,16 @@ sub get_metadata_for_input { return \@metadata; } +sub get_metadata_for_input { + my $self = shift; + my $metadata = $self->get_metadata_for_editing; + + # Also ignore any we have with a 'server_set' automated attribute + my @metadata = grep { !$_->{automated} || $_->{automated} ne 'server_set' } @$metadata; + + return \@metadata; +} + sub id_field { my $self = shift; return $self->get_extra_metadata('id_field') || 'fixmystreet_id'; diff --git a/perllib/FixMyStreet/DB/Result/ContactDefectType.pm b/perllib/FixMyStreet/DB/Result/ContactDefectType.pm index 2199f0b42..25d842e23 100644 --- a/perllib/FixMyStreet/DB/Result/ContactDefectType.pm +++ b/perllib/FixMyStreet/DB/Result/ContactDefectType.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("contact_defect_types"); __PACKAGE__->add_columns( "id", @@ -38,8 +42,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VIczmM0OXXpWgQVpop3SMw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yjQ/+17jn8fW8J70fFtvgg # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/ContactResponsePriority.pm b/perllib/FixMyStreet/DB/Result/ContactResponsePriority.pm index d5afd75a7..8406e2762 100644 --- a/perllib/FixMyStreet/DB/Result/ContactResponsePriority.pm +++ b/perllib/FixMyStreet/DB/Result/ContactResponsePriority.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("contact_response_priorities"); __PACKAGE__->add_columns( "id", @@ -38,8 +42,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-09-06 15:33:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:kM/9jY1QSgakyPTvutS+hw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:NvXWYJu14GUXEHztl3Zp4w # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/ContactResponseTemplate.pm b/perllib/FixMyStreet/DB/Result/ContactResponseTemplate.pm index 3c777533c..3139b2c84 100644 --- a/perllib/FixMyStreet/DB/Result/ContactResponseTemplate.pm +++ b/perllib/FixMyStreet/DB/Result/ContactResponseTemplate.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("contact_response_templates"); __PACKAGE__->add_columns( "id", @@ -38,8 +42,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-08-24 11:29:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:d6niNsxi2AsijhvJSuQeKw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:PE5+8AZp77pb+tDFEwiOqg # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/ContactsHistory.pm b/perllib/FixMyStreet/DB/Result/ContactsHistory.pm index c90bb9d66..5a6039d6a 100644 --- a/perllib/FixMyStreet/DB/Result/ContactsHistory.pm +++ b/perllib/FixMyStreet/DB/Result/ContactsHistory.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("contacts_history"); __PACKAGE__->add_columns( "contacts_history_id", @@ -26,20 +30,20 @@ __PACKAGE__->add_columns( { data_type => "text", default_value => "Other", is_nullable => 0 }, "email", { data_type => "text", is_nullable => 0 }, + "state", + { data_type => "text", is_nullable => 0 }, "editor", { data_type => "text", is_nullable => 0 }, "whenedited", { data_type => "timestamp", is_nullable => 0 }, "note", { data_type => "text", is_nullable => 0 }, - "state", - { data_type => "text", is_nullable => 0 }, ); __PACKAGE__->set_primary_key("contacts_history_id"); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-07-08 20:45:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HTt0g29yXTM/WyHKN179FA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:es6F6L3MS8pEUDprFplnYg # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/DefectType.pm b/perllib/FixMyStreet/DB/Result/DefectType.pm index a2969f59e..baee066af 100644 --- a/perllib/FixMyStreet/DB/Result/DefectType.pm +++ b/perllib/FixMyStreet/DB/Result/DefectType.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("defect_types"); __PACKAGE__->add_columns( "id", @@ -49,8 +53,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BBLjb/aAoTKJZerdYCeBMQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:d5Gkeysiz/1P/Ww4Xur0vA __PACKAGE__->many_to_many( contacts => 'contact_defect_types', 'contact' ); diff --git a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm index d7240cd5d..18d2a7683 100644 --- a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm +++ b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("moderation_original_data"); __PACKAGE__->add_columns( "id", @@ -37,9 +41,16 @@ __PACKAGE__->add_columns( is_nullable => 0, original => { default_value => \"now()" }, }, + "extra", + { data_type => "text", is_nullable => 1 }, + "category", + { data_type => "text", is_nullable => 1 }, + "latitude", + { data_type => "double precision", is_nullable => 1 }, + "longitude", + { data_type => "double precision", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); -__PACKAGE__->add_unique_constraint("moderation_original_data_comment_id_key", ["comment_id"]); __PACKAGE__->belongs_to( "comment", "FixMyStreet::DB::Result::Comment", @@ -59,9 +70,149 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:DBtGjCJykDtLnGtkj638eA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:FLKiZELcfBcc9VwHU2MZYQ + +use Moo; +use Text::Diff; +use Data::Dumper; + +with 'FixMyStreet::Roles::Extra'; + +__PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); +__PACKAGE__->rabx_column('extra'); + +sub admin_log { + my $self = shift; + my $rs = $self->result_source->schema->resultset("AdminLog"); + my $log = $rs->search({ + object_id => $self->id, + object_type => 'moderation', + })->first; + return $log; +} + +sub compare_with { + my ($self, $other) = @_; + if ($self->comment_id) { + my $new_detail = $other->can('text') ? $other->text : $other->detail; + return { + detail => string_diff($self->detail, $new_detail), + photo => $self->compare_photo($other), + anonymous => $self->compare_anonymous($other), + extra => $self->compare_extra($other), + }; + } + return { + title => string_diff($self->title, $other->title), + detail => string_diff($self->detail, $other->detail), + photo => $self->compare_photo($other), + anonymous => $self->compare_anonymous($other), + coords => $self->compare_coords($other), + category => string_diff($self->category, $other->category, single => 1), + extra => $self->compare_extra($other), + } +} + +sub compare_anonymous { + my ($self, $other) = @_; + string_diff( + $self->anonymous ? _('Yes') : _('No'), + $other->anonymous ? _('Yes') : _('No'), + ); +} + +sub compare_coords { + my ($self, $other) = @_; + return '' unless $self->latitude && $self->longitude; + my $old = join ',', $self->latitude, $self->longitude; + my $new = join ',', $other->latitude, $other->longitude; + string_diff($old, $new, single => 1); +} + +sub compare_photo { + my ($self, $other) = @_; + + my $old = $self->photo || ''; + my $new = $other->photo || ''; + return '' if $old eq $new; + + $old = [ split /,/, $old ]; + $new = [ split /,/, $new ]; + + my $diff = Algorithm::Diff->new( $old, $new ); + my (@added, @deleted); + while ( $diff->Next ) { + next if $diff->Same; + push @deleted, $diff->Items(1); + push @added, $diff->Items(2); + } + return (join ', ', map { + "<del style='background-color:#fcc'>$_</del>"; + } @deleted) . (join ', ', map { + "<ins style='background-color:#cfc'>$_</ins>"; + } @added); +} + +sub compare_extra { + my ($self, $other) = @_; + + my $old = $self->get_extra_metadata; + my $new = $other->get_extra_metadata; + + my $both = { %$old, %$new }; + my @all_keys = sort keys %$both; + my @s; + foreach (@all_keys) { + if ($old->{$_} && $new->{$_}) { + push @s, string_diff("$_ = $old->{$_}", "$_ = $new->{$_}"); + } elsif ($new->{$_}) { + push @s, string_diff("", "$_ = $new->{$_}"); + } else { + push @s, string_diff("$_ = $old->{$_}", ""); + } + } + return join ', ', grep { $_ } @s; +} + +sub extra_diff { + my ($self, $other, $key) = @_; + my $o = $self->get_extra_metadata($key); + my $n = $other->get_extra_metadata($key); + return string_diff($o, $n); +} + +sub string_diff { + my ($old, $new, %options) = @_; + + return '' if $old eq $new; + + $old = FixMyStreet::Template::html_filter($old); + $new = FixMyStreet::Template::html_filter($new); + if ($options{single}) { + return unless $old; + $old = [ $old ]; + $new = [ $new ]; + } + $old = [ split //, $old ] unless ref $old; + $new = [ split //, $new ] unless ref $new; + my $diff = Algorithm::Diff->new( $old, $new ); + my $string; + while ($diff->Next) { + my $d = $diff->Diff; + if ($d & 1) { + my $deleted = join '', $diff->Items(1); + $string .= "<del style='background-color:#fcc'>$deleted</del>"; + } + my $inserted = join '', $diff->Items(2); + if ($d & 2) { + $string .= "<ins style='background-color:#cfc'>$inserted</ins>"; + } else { + $string .= $inserted; + } + } + return $string; +} -# You can replace this text with custom code or comments, and it will be preserved on regeneration 1; diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm index f67e0b0f8..dc45091ee 100644 --- a/perllib/FixMyStreet/DB/Result/Problem.pm +++ b/perllib/FixMyStreet/DB/Result/Problem.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("problem"); __PACKAGE__->add_columns( "id", @@ -20,14 +24,38 @@ __PACKAGE__->add_columns( }, "postcode", { data_type => "text", is_nullable => 0 }, + "latitude", + { data_type => "double precision", is_nullable => 0 }, + "longitude", + { data_type => "double precision", is_nullable => 0 }, + "bodies_str", + { data_type => "text", is_nullable => 1 }, + "bodies_missing", + { data_type => "text", is_nullable => 1 }, + "areas", + { data_type => "text", is_nullable => 0 }, + "category", + { data_type => "text", default_value => "Other", is_nullable => 0 }, "title", { data_type => "text", is_nullable => 0 }, "detail", { data_type => "text", is_nullable => 0 }, "photo", { data_type => "bytea", is_nullable => 1 }, + "used_map", + { data_type => "boolean", is_nullable => 0 }, + "user_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, "name", { data_type => "text", is_nullable => 0 }, + "anonymous", + { data_type => "boolean", is_nullable => 0 }, + "external_id", + { data_type => "text", is_nullable => 1 }, + "external_body", + { data_type => "text", is_nullable => 1 }, + "external_team", + { data_type => "text", is_nullable => 1 }, "created", { data_type => "timestamp", @@ -35,57 +63,37 @@ __PACKAGE__->add_columns( is_nullable => 0, original => { default_value => \"now()" }, }, - "state", - { data_type => "text", is_nullable => 0 }, - "whensent", - { data_type => "timestamp", is_nullable => 1 }, - "used_map", - { data_type => "boolean", is_nullable => 0 }, - "bodies_str", - { data_type => "text", is_nullable => 1 }, - "anonymous", - { data_type => "boolean", is_nullable => 0 }, - "category", - { data_type => "text", default_value => "Other", is_nullable => 0 }, "confirmed", { data_type => "timestamp", is_nullable => 1 }, - "send_questionnaire", - { data_type => "boolean", default_value => \"true", is_nullable => 0 }, - "lastupdate", - { - data_type => "timestamp", - default_value => \"current_timestamp", - is_nullable => 0, - original => { default_value => \"now()" }, - }, - "areas", + "state", { data_type => "text", is_nullable => 0 }, - "service", - { data_type => "text", default_value => "", is_nullable => 0 }, "lang", { data_type => "text", default_value => "en-gb", is_nullable => 0 }, + "service", + { data_type => "text", default_value => "", is_nullable => 0 }, "cobrand", { data_type => "text", default_value => "", is_nullable => 0 }, "cobrand_data", { data_type => "text", default_value => "", is_nullable => 0 }, - "latitude", - { data_type => "double precision", is_nullable => 0 }, - "longitude", - { data_type => "double precision", is_nullable => 0 }, - "external_id", - { data_type => "text", is_nullable => 1 }, - "external_body", - { data_type => "text", is_nullable => 1 }, - "external_team", + "lastupdate", + { + data_type => "timestamp", + default_value => \"current_timestamp", + is_nullable => 0, + original => { default_value => \"now()" }, + }, + "whensent", + { data_type => "timestamp", is_nullable => 1 }, + "send_questionnaire", + { data_type => "boolean", default_value => \"true", is_nullable => 0 }, + "extra", { data_type => "text", is_nullable => 1 }, - "user_id", - { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, "flagged", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, - "extra", - { data_type => "text", is_nullable => 1 }, "geocode", { data_type => "bytea", is_nullable => 1 }, + "response_priority_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "send_fail_count", { data_type => "integer", default_value => 0, is_nullable => 0 }, "send_fail_reason", @@ -104,10 +112,6 @@ __PACKAGE__->add_columns( { data_type => "integer", default_value => 0, is_nullable => 1 }, "subcategory", { data_type => "text", is_nullable => 1 }, - "bodies_missing", - { data_type => "text", is_nullable => 1 }, - "response_priority_id", - { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "defect_type_id", { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, ); @@ -166,8 +170,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:8zzWlJX7OQOdvrGxKuZUmg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:hUXle+TtlkDkxkBrVa/u+g # Add fake relationship to stored procedure table __PACKAGE__->has_one( @@ -177,11 +181,15 @@ __PACKAGE__->has_one( { cascade_copy => 0, cascade_delete => 0 }, ); +# This will return the oldest moderation_original_data, if any. +# The plural can be used to return all entries. __PACKAGE__->might_have( "moderation_original_data", "FixMyStreet::DB::Result::ModerationOriginalData", { "foreign.problem_id" => "self.id" }, { where => { 'comment_id' => undef }, + order_by => 'id', + rows => 1, cascade_copy => 0, cascade_delete => 1 }, ); @@ -206,6 +214,7 @@ my $IM = eval { with 'FixMyStreet::Roles::Abuser', 'FixMyStreet::Roles::Extra', + 'FixMyStreet::Roles::Moderation', 'FixMyStreet::Roles::Translatable', 'FixMyStreet::Roles::PhotoSet'; @@ -318,19 +327,6 @@ sub visible_states_remove { } } -my $stz = sub { - my ( $orig, $self ) = ( shift, shift ); - my $s = $self->$orig(@_); - return $s unless $s && UNIVERSAL::isa($s, "DateTime"); - FixMyStreet->set_time_zone($s); - return $s; -}; - -around created => $stz; -around confirmed => $stz; -around whensent => $stz; -around lastupdate => $stz; - around service => sub { my ( $orig, $self ) = ( shift, shift ); # service might be undef if e.g. unsaved code report @@ -663,7 +659,7 @@ sub body { my @body_names = sort map { my $name = $_->name; if ($c and FixMyStreet->config('AREA_LINKS_FROM_PROBLEMS')) { - '<a href="' . $_->url($c) . '">' . $name . '</a>'; + '<a href="' . $_->url . '">' . $name . '</a>'; } else { $name; } @@ -759,7 +755,7 @@ sub defect_types { # Note: this only makes sense when called on a problem that has been sent! sub can_display_external_id { my $self = shift; - if ($self->external_id && $self->send_method_used && $self->to_body_named('Oxfordshire|Angus')) { + if ($self->external_id && $self->send_method_used && $self->to_body_named('Oxfordshire|Lincolnshire')) { return 1; } return 0; @@ -780,7 +776,7 @@ sub duration_string { sub local_coords { my $self = shift; - my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($self->cobrand)->new; + my $cobrand = $self->get_cobrand_logged; if ($cobrand->moniker eq 'zurich') { my ($x, $y) = Geo::Coordinates::CH1903Plus::from_latlon($self->latitude, $self->longitude); return ( int($x+0.5), int($y+0.5) ); @@ -893,11 +889,12 @@ bodies by some mechanism. Right now that mechanism is Open311. sub updates_sent_to_body { my $self = shift; - return unless $self->send_method_used && $self->send_method_used eq 'Open311'; + return unless $self->send_method_used && $self->send_method_used =~ /Open311/; # Some bodies only send updates *to* FMS, they don't receive updates. - # NB See also the list in bin/send-comments - my $excluded = qr{Lewisham|Oxfordshire}; + my $cobrand = $self->get_cobrand_logged; + my $handler = $cobrand->call_hook(get_body_handler_for_problem => $self); + return 0 if $handler && $handler->call_hook('open311_post_update_skip'); my @bodies = values %{ $self->bodies }; my @updates_sent = grep { @@ -905,8 +902,7 @@ sub updates_sent_to_body { ( $_->send_method eq 'Open311' || $_->send_method eq 'Noop' # Sending might be temporarily disabled - ) && - !($_->name =~ /$excluded/) + ) } @bodies; return scalar @updates_sent; } @@ -922,6 +918,12 @@ sub add_send_method { } } +sub resend { + my $self = shift; + $self->whensent(undef); + $self->send_method_used(undef); +} + sub as_hashref { my ($self, $c, $cols) = @_; @@ -952,17 +954,6 @@ sub as_hashref { return $out; } -=head2 latest_moderation_log_entry - -Return most recent ModerationLog object - -=cut - -sub latest_moderation_log_entry { - my $self = shift; - return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => { -desc => 'id' } })->first; -} - __PACKAGE__->has_many( "admin_log_entries", "FixMyStreet::DB::Result::AdminLog", @@ -973,6 +964,11 @@ __PACKAGE__->has_many( } ); +sub moderation_filter { + my $self = shift; + { comment_id => undef }; +} + sub get_time_spent { my $self = shift; my $admin_logs = $self->admin_log_entries->search({}, @@ -1017,6 +1013,7 @@ sub pin_data { id => $self->id, title => $title, problem => $self, + draggable => $opts{draggable}, type => $opts{type}, } }; diff --git a/perllib/FixMyStreet/DB/Result/Questionnaire.pm b/perllib/FixMyStreet/DB/Result/Questionnaire.pm index 30f2ab7ce..2d5445669 100644 --- a/perllib/FixMyStreet/DB/Result/Questionnaire.pm +++ b/perllib/FixMyStreet/DB/Result/Questionnaire.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("questionnaire"); __PACKAGE__->add_columns( "id", @@ -40,21 +44,17 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:oL1Hk4/bNG14CY74GA75SA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:AWRb6itjsVkG5VUDRmBTIg use Moo; use namespace::clean -except => [ 'meta' ]; -my $stz = sub { - my ( $orig, $self ) = ( shift, shift ); - my $s = $self->$orig(@_); - return $s unless $s && UNIVERSAL::isa($s, "DateTime"); - FixMyStreet->set_time_zone($s); - return $s; -}; - -around whensent => $stz; -around whenanswered => $stz; +sub marks_fixed { + my $self = shift; + my $new_fixed = FixMyStreet::DB::Result::Problem->fixed_states()->{$self->new_state}; + my $old_fixed = FixMyStreet::DB::Result::Problem->fixed_states()->{$self->old_state}; + return $new_fixed && !$old_fixed; +} 1; diff --git a/perllib/FixMyStreet/DB/Result/ReportExtraFields.pm b/perllib/FixMyStreet/DB/Result/ReportExtraField.pm index 27a6bd2c6..f88169bba 100644 --- a/perllib/FixMyStreet/DB/Result/ReportExtraFields.pm +++ b/perllib/FixMyStreet/DB/Result/ReportExtraField.pm @@ -1,5 +1,5 @@ use utf8; -package FixMyStreet::DB::Result::ReportExtraFields; +package FixMyStreet::DB::Result::ReportExtraField; # Created by DBIx::Class::Schema::Loader # DO NOT MODIFY THE FIRST PART OF THIS FILE @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("report_extra_fields"); __PACKAGE__->add_columns( "id", @@ -30,8 +34,8 @@ __PACKAGE__->add_columns( __PACKAGE__->set_primary_key("id"); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-07-28 09:51:34 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:LkfbsUInnEyXowdcCEPjUQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 15:41:27 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yRF676ybdkfalMwZ9V+yhw __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); diff --git a/perllib/FixMyStreet/DB/Result/ResponsePriority.pm b/perllib/FixMyStreet/DB/Result/ResponsePriority.pm index df54cfa08..a478ac7b9 100644 --- a/perllib/FixMyStreet/DB/Result/ResponsePriority.pm +++ b/perllib/FixMyStreet/DB/Result/ResponsePriority.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("response_priorities"); __PACKAGE__->add_columns( "id", @@ -20,10 +24,10 @@ __PACKAGE__->add_columns( }, "body_id", { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, - "name", - { data_type => "text", is_nullable => 0 }, "deleted", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0 }, "description", { data_type => "text", is_nullable => 1 }, "external_id", @@ -53,8 +57,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-09-12 09:32:53 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:JBIHFnaLvXCAUjgwTSB3CQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:gIttzSJcQ8GxTowrQZ8oAw __PACKAGE__->many_to_many( contacts => 'contact_response_priorities', 'contact' ); diff --git a/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm index 73e0d898e..85bf80aef 100644 --- a/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm +++ b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("response_templates"); __PACKAGE__->add_columns( "id", @@ -54,8 +58,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07048 @ 2018-03-22 11:18:36 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:p0+/jFma6H9eZ3MZAJQRaQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:MzTa7p2rryKkxbRi7zN+Uw __PACKAGE__->many_to_many( contacts => 'contact_response_templates', 'contact' ); diff --git a/perllib/FixMyStreet/DB/Result/Secret.pm b/perllib/FixMyStreet/DB/Result/Secret.pm index 449dfec0e..045375fef 100644 --- a/perllib/FixMyStreet/DB/Result/Secret.pm +++ b/perllib/FixMyStreet/DB/Result/Secret.pm @@ -8,13 +8,17 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("secret"); __PACKAGE__->add_columns("secret", { data_type => "text", is_nullable => 0 }); -# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9XiWSKJ1PD3LSYjrSA3drw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:mVU/XGxS3DVhEcHTA2srgA # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/Session.pm b/perllib/FixMyStreet/DB/Result/Session.pm index a478c5444..94f7e823c 100644 --- a/perllib/FixMyStreet/DB/Result/Session.pm +++ b/perllib/FixMyStreet/DB/Result/Session.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("sessions"); __PACKAGE__->add_columns( "id", @@ -21,8 +25,8 @@ __PACKAGE__->add_columns( __PACKAGE__->set_primary_key("id"); -# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:MVmCn4gLQWXTDIIaDHiVmA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HoYrCwULpxJVJ1m9ASMk3A use Storable; use MIME::Base64; diff --git a/perllib/FixMyStreet/DB/Result/State.pm b/perllib/FixMyStreet/DB/Result/State.pm index b8a35d42b..66477111b 100644 --- a/perllib/FixMyStreet/DB/Result/State.pm +++ b/perllib/FixMyStreet/DB/Result/State.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("state"); __PACKAGE__->add_columns( "id", @@ -30,8 +34,8 @@ __PACKAGE__->add_unique_constraint("state_label_key", ["label"]); __PACKAGE__->add_unique_constraint("state_name_key", ["name"]); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-08-22 15:17:43 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:dvtAOpeYqEF9T3otHHgLqw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:f/QeR3FYL/4wIGRu3c/C/A use Moo; use namespace::clean; diff --git a/perllib/FixMyStreet/DB/Result/Token.pm b/perllib/FixMyStreet/DB/Result/Token.pm index a60e23839..444d5e5a8 100644 --- a/perllib/FixMyStreet/DB/Result/Token.pm +++ b/perllib/FixMyStreet/DB/Result/Token.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("token"); __PACKAGE__->add_columns( "scope", @@ -28,8 +32,8 @@ __PACKAGE__->add_columns( __PACKAGE__->set_primary_key("scope", "token"); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HkvzOY5STjOdXN64hxg5NA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:km/1K3PurX8bbgnYPWgLIA use mySociety::AuthToken; diff --git a/perllib/FixMyStreet/DB/Result/Translation.pm b/perllib/FixMyStreet/DB/Result/Translation.pm index fafc7ccf1..4d6373d40 100644 --- a/perllib/FixMyStreet/DB/Result/Translation.pm +++ b/perllib/FixMyStreet/DB/Result/Translation.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("translation"); __PACKAGE__->add_columns( "id", @@ -36,8 +40,8 @@ __PACKAGE__->add_unique_constraint( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-07-14 23:24:32 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:///VNqg4BOuO29xKhnY8vw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:EsseG51ZpQa5QYHPCpkL8A # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index 8b539f85d..d01ba92d0 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("users"); __PACKAGE__->add_columns( "id", @@ -36,16 +40,6 @@ __PACKAGE__->add_columns( { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "is_superuser", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, - "title", - { data_type => "text", is_nullable => 1 }, - "twitter_id", - { data_type => "bigint", is_nullable => 1 }, - "facebook_id", - { data_type => "bigint", is_nullable => 1 }, - "area_id", - { data_type => "integer", is_nullable => 1 }, - "extra", - { data_type => "text", is_nullable => 1 }, "created", { data_type => "timestamp", @@ -60,6 +54,16 @@ __PACKAGE__->add_columns( is_nullable => 0, original => { default_value => \"now()" }, }, + "title", + { data_type => "text", is_nullable => 1 }, + "twitter_id", + { data_type => "bigint", is_nullable => 1 }, + "facebook_id", + { data_type => "bigint", is_nullable => 1 }, + "extra", + { data_type => "text", is_nullable => 1 }, + "area_ids", + { data_type => "integer[]", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->add_unique_constraint("users_facebook_id_key", ["facebook_id"]); @@ -119,8 +123,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2018-05-23 18:54:36 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:/V7+Ygv/t6VX8dDhNGN16w +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BCCqv3JCec8psuRk/SdCJQ # These are not fully unique constraints (they only are when the *_verified # is true), but this is managed in ResultSet::User's find() wrapper. @@ -131,6 +135,7 @@ __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); use Moo; +use Text::CSV; use FixMyStreet::SMS; use mySociety::EmailUtil; use namespace::clean -except => [ 'meta' ]; @@ -175,8 +180,8 @@ sub phone_display { sub latest_anonymity { my $self = shift; - my $p = $self->problems->search(undef, { order_by => { -desc => 'id' } } )->first; - my $c = $self->comments->search(undef, { order_by => { -desc => 'id' } } )->first; + my $p = $self->problems->search(undef, { rows => 1, order_by => { -desc => 'id' } } )->first; + my $c = $self->comments->search(undef, { rows => 1, order_by => { -desc => 'id' } } )->first; my $p_created = $p ? $p->created->epoch : 0; my $c_created = $c ? $c->created->epoch : 0; my $obj = $p_created >= $c_created ? $p : $c; @@ -291,6 +296,11 @@ sub body { return $self->from_body->name; } +sub moderating_user_name { + my $self = shift; + return $self->body || _('an administrator'); +} + =head2 belongs_to_body $belongs_to_body = $user->belongs_to_body( $bodies ); @@ -329,6 +339,37 @@ sub split_name { return { first => $first || '', last => $last || '' }; } +sub can_moderate { + my ($self, $object, $perms) = @_; + + my ($type, $ids); + if ($object->isa("FixMyStreet::DB::Result::Comment")) { + $type = 'update'; + $ids = $object->problem->bodies_str_ids; + } else { + $type = 'problem'; + $ids = $object->bodies_str_ids; + } + + my $staff_perm = exists($perms->{staff}) ? $perms->{staff} : $self->has_permission_to(moderate => $ids); + return 1 if $staff_perm; + + # See if the cobrand wants to allow it in some circumstance + my $cobrand = $self->result_source->schema->cobrand; + return $cobrand->call_hook('moderate_permission', $self, $type => $object); +} + +sub can_moderate_title { + my ($self, $problem, $perm) = @_; + + # Must have main permission, this is to potentially restrict only + return 0 unless $perm; + + # If hook returns anything use it, otherwise default to yes + my $cobrand = $self->result_source->schema->cobrand; + return $cobrand->call_hook('moderate_permission_title', $self, $problem) // 1; +} + has body_permissions => ( is => 'ro', lazy => 1, @@ -339,13 +380,16 @@ has body_permissions => ( ); sub permissions { - my ($self, $c, $body_id) = @_; + my ($self, $problem) = @_; + my $cobrand = $self->result_source->schema->cobrand; if ($self->is_superuser) { - my $perms = $c->cobrand->available_permissions; + my $perms = $cobrand->available_permissions; return { map { %$_ } values %$perms }; } + my $body_id = $problem->bodies_str; + return unless $self->belongs_to_body($body_id); my @permissions = grep { $_->body_id == $self->from_body->id } @{$self->body_permissions}; @@ -544,6 +588,17 @@ has categories => ( }, ); +has categories_string => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $csv = Text::CSV->new; + $csv->combine(@{$self->categories}); + return $csv->string; + }, +); + sub set_last_active { my $self = shift; my $time = shift; @@ -551,4 +606,19 @@ sub set_last_active { $self->last_active($time or \'current_timestamp'); } +has areas_hash => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my %ids = map { $_ => 1 } @{$self->area_ids || []}; + return \%ids; + }, +); + +sub in_area { + my ($self, $area) = @_; + return $self->areas_hash->{$area}; +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm b/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm index a118a1996..8fdabbdda 100644 --- a/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm +++ b/perllib/FixMyStreet/DB/Result/UserBodyPermission.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("user_body_permissions"); __PACKAGE__->add_columns( "id", @@ -44,8 +48,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-06-05 15:46:02 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:IWy2rYBU7WP6MyIkLYsc9Q +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:mcgnPaCmEuLWdzB3GuQiTg # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/UserPlannedReport.pm b/perllib/FixMyStreet/DB/Result/UserPlannedReport.pm index 1e893c7a9..cd1716f02 100644 --- a/perllib/FixMyStreet/DB/Result/UserPlannedReport.pm +++ b/perllib/FixMyStreet/DB/Result/UserPlannedReport.pm @@ -8,7 +8,11 @@ use strict; use warnings; use base 'DBIx::Class::Core'; -__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->load_components( + "FilterColumn", + "FixMyStreet::InflateColumn::DateTime", + "FixMyStreet::EncodedColumn", +); __PACKAGE__->table("user_planned_reports"); __PACKAGE__->add_columns( "id", @@ -47,8 +51,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-07-20 15:03:08 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:mv7koDhvZSBW/4aQivtpAQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:A9ICDFNVzkmd/erdtYdeVA # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/ResultSet/Body.pm b/perllib/FixMyStreet/DB/ResultSet/Body.pm index 0aa3e8240..4e9661d2e 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Body.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Body.pm @@ -41,7 +41,7 @@ This restricts the ResultSet to bodies that are not marked as deleted. sub active { my $rs = shift; - $rs->search({ deleted => 0 }); + $rs->search({ 'me.deleted' => 0 }); } =item translated @@ -61,6 +61,22 @@ sub translated { }); } +=item with_parent_name + +This adds the parent name associated with each body to the ResultSet, +in the parent_name column. + +=cut + +sub with_parent_name { + my $rs = shift; + $rs->search(undef, { + '+select' => [ 'parent.name' ], + '+as' => [ 'parent_name' ], + join => 'parent', + }); +} + =item with_area_count This adds the number of areas associated with each body to the ResultSet, @@ -78,10 +94,45 @@ sub with_area_count { }); } +=item with_defect_type_count + +This adds the number of defect types associated with each body to the +ResultSet, in the defect_type_count column. + +=cut + +sub with_defect_type_count { + my $rs = shift; + $rs->search(undef, { + '+select' => [ { count => 'defect_types.name' } ], + '+as' => [ 'defect_type_count' ], + join => 'defect_types', + distinct => 1, + }); +} + +=item with_children_count + +This adds the number of children associated with each body to the +ResultSet, in the children_count column. + +=cut + +sub with_children_count { + my $rs = shift; + $rs->search(undef, { + '+select' => [ { count => 'bodies.id' } ], + '+as' => [ 'children_count' ], + join => 'bodies', + distinct => 1, + }); +} + =item all_sorted -This returns all results, as C<all()>, but sorted by their name column -(which will be the translated names if present). +This returns all results, as C<all()>, but sorted by their name (including +the translated names, if present), and as simple hashrefs not objects, for +performance reasons. =back @@ -89,8 +140,34 @@ This returns all results, as C<all()>, but sorted by their name column sub all_sorted { my $rs = shift; - my @bodies = $rs->all; - @bodies = sort { strcoll($a->name, $b->name) } @bodies; + + # Use a HashRefInflator here to return simple hashrefs rather than full + # objects. This is quicker if you have a large number of bodies; note + # fetching only the columns you need provides even more of a speed up. + my @bodies = $rs->search(undef, { + result_class => 'DBIx::Class::ResultClass::HashRefInflator', + })->all; + @bodies = sort { strcoll($a->{msgstr} || $a->{name}, $b->{msgstr} || $b->{name}) } @bodies; + + my $cobrand = $rs->result_source->schema->cobrand; + + foreach my $body (@bodies) { + $body->{parent} = { id => $body->{parent}, name => $body->{parent_name} } if $body->{parent}; + + # DEPRECATED: url(c, query_params) -> url + $body->{url} = sub { + my ($c, $args) = @_; + return FixMyStreet::DB::Result::Body::_url($body, $cobrand, $args); + }; + + # DEPRECATED: get_column('area_count') -> area_count + next unless defined $body->{area_count}; + $body->{get_column} = sub { + my $key = shift; + return $body->{$key}; + }; + } + return @bodies; } diff --git a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm index b075e3664..2ebe309e3 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm @@ -10,31 +10,34 @@ sub to_body { } sub nearby { - my ( $rs, $c, $dist, $ids, $limit, $mid_lat, $mid_lon, $categories, $states, $extra_params ) = @_; + my ( $rs, $c, %args ) = @_; - unless ( $states ) { - $states = FixMyStreet::DB::Result::Problem->visible_states(); + unless ( $args{states} ) { + $args{states} = FixMyStreet::DB::Result::Problem->visible_states(); } my $params = { - state => [ keys %$states ], + state => [ keys %{$args{states}} ], }; - $params->{id} = { -not_in => $ids } - if $ids; - $params->{category} = $categories if $categories && @$categories; + $params->{id} = { -not_in => $args{ids} } + if $args{ids}; + $params->{category} = $args{categories} if $args{categories} && @{$args{categories}}; + + $params->{$c->stash->{report_age_field}} = { '>=', \"current_timestamp-'$args{report_age}'::interval" } + if $args{report_age}; FixMyStreet::DB::ResultSet::Problem->non_public_if_possible($params, $c); $rs = $c->cobrand->problems_restriction($rs); # Add in any optional extra query parameters - $params = { %$params, %$extra_params } if $extra_params; + $params = { %$params, %{$args{extra}} } if $args{extra}; my $attrs = { prefetch => 'problem', - bind => [ $mid_lat, $mid_lon, $dist ], + bind => [ $args{latitude}, $args{longitude}, $args{distance} ], order_by => [ 'distance', { -desc => 'created' } ], - rows => $limit, + rows => $args{limit}, }; my @problems = mySociety::Locale::in_gb_locale { $rs->search( $params, $attrs )->all }; diff --git a/perllib/FixMyStreet/DB/ResultSet/Problem.pm b/perllib/FixMyStreet/DB/ResultSet/Problem.pm index ef078ed08..37fc34057 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Problem.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Problem.pm @@ -28,13 +28,23 @@ sub body_query { sub non_public_if_possible { my ($rs, $params, $c) = @_; if ($c->user_exists) { + my $only_non_public = $c->stash->{only_non_public} ? 1 : 0; if ($c->user->is_superuser) { # See all reports, no restriction - } elsif ($c->user->has_body_permission_to('report_inspect')) { - $params->{'-or'} = [ - non_public => 0, - $rs->body_query($c->user->from_body->id), - ]; + $params->{non_public} = 1 if $only_non_public; + } elsif ($c->user->has_body_permission_to('report_inspect') || + $c->user->has_body_permission_to('report_mark_private')) { + if ($only_non_public) { + $params->{'-and'} = [ + non_public => 1, + $rs->body_query($c->user->from_body->id), + ]; + } else { + $params->{'-or'} = [ + non_public => 0, + $rs->body_query($c->user->from_body->id), + ]; + } } else { $params->{non_public} = 0; } @@ -57,6 +67,10 @@ sub to_body { # Front page statistics +sub _cache_timeout { + FixMyStreet->config('CACHE_TIMEOUT') // 3600; +} + sub recent_fixed { my $rs = shift; my $key = "recent_fixed:$site_key"; @@ -66,7 +80,7 @@ sub recent_fixed { state => [ FixMyStreet::DB::Result::Problem->fixed_states() ], lastupdate => { '>', \"current_timestamp-'1 month'::interval" }, } )->count; - Memcached::set($key, $result, 3600); + Memcached::set($key, $result, _cache_timeout()); } return $result; } @@ -80,7 +94,7 @@ sub number_comments { { 'comments.state' => 'confirmed' }, { join => 'comments' } )->count; - Memcached::set($key, $result, 3600); + Memcached::set($key, $result, _cache_timeout()); } return $result; } @@ -95,7 +109,7 @@ sub recent_new { state => [ FixMyStreet::DB::Result::Problem->visible_states() ], confirmed => { '>', \"current_timestamp-'$interval'::interval" }, } )->count; - Memcached::set($key, $result, 3600); + Memcached::set($key, $result, _cache_timeout()); } return $result; } @@ -144,10 +158,10 @@ sub _recent { # Need to reattach schema so that confirmed column gets reinflated. $probs->[0]->result_source->schema( $rs->result_source->schema ) if $probs->[0]; # Catch any cached ones since hidden - $probs = [ grep { ! $_->is_hidden } @$probs ]; + $probs = [ grep { $_->photo && ! $_->is_hidden } @$probs ]; } else { $probs = [ $rs->search( $query, $attrs )->all ]; - Memcached::set($key, $probs, 3600); + Memcached::set($key, $probs, _cache_timeout()); } } @@ -172,6 +186,9 @@ sub around_map { latitude => { '>=', $p{min_lat}, '<', $p{max_lat} }, longitude => { '>=', $p{min_lon}, '<', $p{max_lon} }, }; + + $q->{$c->stash->{report_age_field}} = { '>=', \"current_timestamp-'$p{report_age}'::interval" } if + $p{report_age}; $q->{category} = $p{categories} if $p{categories} && @{$p{categories}}; $rs->non_public_if_possible($q, $c); @@ -198,9 +215,9 @@ sub timeline { return $rs->search( { -or => { - created => { '>=', \"current_timestamp-'7 days'::interval" }, - confirmed => { '>=', \"current_timestamp-'7 days'::interval" }, - whensent => { '>=', \"current_timestamp-'7 days'::interval" }, + 'me.created' => { '>=', \"current_timestamp-'7 days'::interval" }, + 'me.confirmed' => { '>=', \"current_timestamp-'7 days'::interval" }, + 'me.whensent' => { '>=', \"current_timestamp-'7 days'::interval" }, } }, { diff --git a/perllib/FixMyStreet/DB/ResultSet/ReportExtraFields.pm b/perllib/FixMyStreet/DB/ResultSet/ReportExtraField.pm index 1348df3c2..9c47b1894 100644 --- a/perllib/FixMyStreet/DB/ResultSet/ReportExtraFields.pm +++ b/perllib/FixMyStreet/DB/ResultSet/ReportExtraField.pm @@ -1,4 +1,4 @@ -package FixMyStreet::DB::ResultSet::ReportExtraFields; +package FixMyStreet::DB::ResultSet::ReportExtraField; use base 'DBIx::Class::ResultSet'; use strict; diff --git a/perllib/FixMyStreet/DateRange.pm b/perllib/FixMyStreet/DateRange.pm new file mode 100644 index 000000000..bc4f4e1af --- /dev/null +++ b/perllib/FixMyStreet/DateRange.pm @@ -0,0 +1,72 @@ +package FixMyStreet::DateRange; + +use DateTime; +use DateTime::Format::Flexible; +use Moo; +use Try::Tiny; + +my $one_day = DateTime::Duration->new( days => 1 ); + +has start_date => ( is => 'ro' ); + +has start_default => ( is => 'ro' ); + +has end_date => ( is => 'ro' ); + +has parser => ( + is => 'ro', + default => sub { DateTime::Format::Flexible->new } +); + +has formatter => ( + is => 'lazy', + default => sub { + my $self = shift; + return $self->parser; + } +); + +sub _dt { + my ($self, $date) = @_; + my %params; + $params{european} = 1 if $self->parser->isa('DateTime::Format::Flexible'); + my $d = try { + $self->parser->parse_datetime($date, %params) + }; + return $d; +} + +sub start { + my $self = shift; + $self->_dt($self->start_date) || $self->start_default +} + +sub end { + my $self = shift; + my $d = $self->_dt($self->end_date); + $d += $one_day if $d; + return $d; +} + +sub _formatted { + my ($self, $dt) = @_; + return unless $dt; + $self->formatter->format_datetime($dt); +} + +sub start_formatted { $_[0]->_formatted($_[0]->start) } +sub end_formatted { $_[0]->_formatted($_[0]->end) } + +sub sql { + my ($self, $default) = @_; + my $sql = {}; + if (my $start = $self->start_formatted) { + $sql->{'>='} = $start; + } + if (my $end = $self->end_formatted) { + $sql->{'<'} = $end; + } + return $sql; +} + +1; diff --git a/perllib/FixMyStreet/Email.pm b/perllib/FixMyStreet/Email.pm index ea84e3966..2b72b5c63 100644 --- a/perllib/FixMyStreet/Email.pm +++ b/perllib/FixMyStreet/Email.pm @@ -22,6 +22,7 @@ use FixMyStreet::Email::Sender; sub test_dmarc { my $email = shift; return if FixMyStreet->test_mode; + return 1 if $email =~ /\@swdevon.gov.uk$/; return Utils::Email::test_dmarc($email); } diff --git a/perllib/FixMyStreet/Geocode.pm b/perllib/FixMyStreet/Geocode.pm index aeac0ab6d..d552afaa5 100644 --- a/perllib/FixMyStreet/Geocode.pm +++ b/perllib/FixMyStreet/Geocode.pm @@ -59,7 +59,7 @@ sub string { sub escape { my ($s, $c) = @_; $s = lc($s); - $s =~ s/[^-&\w ']/ /g; + $s =~ s/[^-&\w ',]/ /g; $s =~ s/\s+/ /g; $s = URI::Escape::uri_escape_utf8($s); $s =~ s/%20/+/g; diff --git a/perllib/FixMyStreet/Geocode/OSM.pm b/perllib/FixMyStreet/Geocode/OSM.pm index 4d57007c5..0d296f299 100644 --- a/perllib/FixMyStreet/Geocode/OSM.pm +++ b/perllib/FixMyStreet/Geocode/OSM.pm @@ -30,7 +30,7 @@ sub string { $s = $params->{string} if $params->{string}; $s = FixMyStreet::Geocode::escape($s); - $s .= '+' . $params->{town} if $params->{town} and $s !~ /$params->{town}/i; + $s .= '%2C+' . $params->{town} if $params->{town} and $s !~ /$params->{town}/i; my $url = "${nominatimbase}search?"; my %query_params = ( diff --git a/perllib/FixMyStreet/ImageMagick.pm b/perllib/FixMyStreet/ImageMagick.pm new file mode 100644 index 000000000..af9f56478 --- /dev/null +++ b/perllib/FixMyStreet/ImageMagick.pm @@ -0,0 +1,69 @@ +package FixMyStreet::ImageMagick; + +use Moo; + +my $IM = eval { + return 0 if FixMyStreet->test_mode; + require Image::Magick; + Image::Magick->import; + 1; +}; + +has blob => ( is => 'ro' ); + +has image => ( + is => 'rwp', + lazy => 1, + default => sub { + my $self = shift; + return unless $IM; + my $image = Image::Magick->new; + $image->BlobToImage($self->blob); + return $image; + }, +); + +sub strip { + my $self = shift; + return $self unless $self->image; + $self->image->Strip(); + return $self; +} + +sub rotate { + my ($self, $direction) = @_; + return $self unless $self->image; + my $err = $self->image->Rotate($direction); + return 0 if $err; + return $self; +} + +# Shrinks a picture to the specified size, but keeping in proportion. +sub shrink { + my ($self, $size) = @_; + return $self unless $self->image; + my $err = $self->image->Scale(geometry => "$size>"); + throw Error::Simple("resize failed: $err") if "$err"; + return $self->strip; +} + +# Shrinks a picture to 90x60, cropping so that it is exactly that. +sub crop { + my $self = shift; + return $self unless $self->image; + my $err = $self->image->Resize( geometry => "90x60^" ); + throw Error::Simple("resize failed: $err") if "$err"; + $err = $self->image->Extent( geometry => '90x60', gravity => 'Center' ); + throw Error::Simple("resize failed: $err") if "$err"; + return $self->strip; +} + +sub as_blob { + my $self = shift; + return $self->blob unless $self->image; + my @blobs = $self->image->ImageToBlob(); + $self->_set_image(undef); + return $blobs[0]; +} + +1; diff --git a/perllib/FixMyStreet/Map.pm b/perllib/FixMyStreet/Map.pm index f5d4c1db6..8b8cfe82c 100644 --- a/perllib/FixMyStreet/Map.pm +++ b/perllib/FixMyStreet/Map.pm @@ -92,23 +92,33 @@ sub map_features { $p{latitude} = Utils::truncate_coordinate(($p{max_lat} + $p{min_lat} ) / 2); } + my $report_age = $c->stash->{show_old_reports} ? undef : $c->cobrand->report_age; + $p{report_age} = $report_age; + $p{page} = $c->get_param('p') || 1; my $on_map = $c->cobrand->problems_on_map->around_map( $c, %p ); my $pager = $c->stash->{pager} = $on_map->pager; $on_map = [ $on_map->all ]; - my $dist = FixMyStreet::Gaze::get_radius_containing_population( $p{latitude}, $p{longitude} ); + if ( $c->{stash}->{show_old_reports} ) { + # if show_old_reports is on then there must be old reports + $c->stash->{num_old_reports} = 1; + } else { + my $older = $c->cobrand->problems_on_map->around_map( $c, %p, report_age => undef, page => 1 ); + $c->stash->{num_old_reports} = $older->pager->total_entries - $pager->total_entries; + } + # if there are fewer entries than our paging limit on the map then + # also return nearby entries for display my $nearby; if (@$on_map < $pager->entries_per_page && $pager->current_page == 1) { - my $limit = 20; - my @ids = map { $_->id } @$on_map; - $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, \@ids, $limit, @p{"latitude", "longitude", "categories", "states", "extra"} - ); + $p{limit} = 20; + $p{ids} = [ map { $_->id } @$on_map ]; + $p{distance} = FixMyStreet::Gaze::get_radius_containing_population( $p{latitude}, $p{longitude} ); + $nearby = $c->model('DB::Nearby')->nearby($c, %p); } - return ( $on_map, $nearby, $dist ); + return ( $on_map, $nearby ); } sub click_to_wgs84 { diff --git a/perllib/FixMyStreet/Map/Angus.pm b/perllib/FixMyStreet/Map/Angus.pm deleted file mode 100644 index 98f5373c1..000000000 --- a/perllib/FixMyStreet/Map/Angus.pm +++ /dev/null @@ -1,18 +0,0 @@ -# FixMyStreet:Map::Angus -# More JavaScript, for street assets - -package FixMyStreet::Map::Angus; -use base 'FixMyStreet::Map::FMS'; - -use strict; - -sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.angus.js', - '/js/map-OpenLayers.js', - '/js/map-bing-ol.js', - '/js/map-fms.js', - '/cobrands/fixmystreet/assets.js', - '/cobrands/angus/js.js', -] } - -1; diff --git a/perllib/FixMyStreet/Map/BathNES.pm b/perllib/FixMyStreet/Map/BathNES.pm index 9c9c3c11d..45261a625 100644 --- a/perllib/FixMyStreet/Map/BathNES.pm +++ b/perllib/FixMyStreet/Map/BathNES.pm @@ -7,12 +7,14 @@ use base 'FixMyStreet::Map::OSM'; use strict; sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.bathnes.js', + '/vendor/OpenLayers/OpenLayers.wfs.js', '/vendor/OpenLayers.Projection.OrdnanceSurvey.js', '/js/map-OpenLayers.js', '/js/map-OpenStreetMap.js', + '/cobrands/fixmystreet-uk-councils/roadworks.js', '/cobrands/fixmystreet/assets.js', '/cobrands/bathnes/js.js', + '/cobrands/bathnes/assets.js', ] } -1;
\ No newline at end of file +1; diff --git a/perllib/FixMyStreet/Map/Bing.pm b/perllib/FixMyStreet/Map/Bing.pm index 68c9fea32..97a0d229f 100644 --- a/perllib/FixMyStreet/Map/Bing.pm +++ b/perllib/FixMyStreet/Map/Bing.pm @@ -30,7 +30,7 @@ sub get_quadkey { } sub map_tile_base { - '', "//ecn.%s.tiles.virtualearth.net/tiles/r%s.png?g=5941"; + '', "//ecn.%s.tiles.virtualearth.net/tiles/r%s.png?g=6570"; } sub map_tiles { diff --git a/perllib/FixMyStreet/Map/Bristol.pm b/perllib/FixMyStreet/Map/Bristol.pm index 5d05fbd34..99bdd26d7 100644 --- a/perllib/FixMyStreet/Map/Bristol.pm +++ b/perllib/FixMyStreet/Map/Bristol.pm @@ -64,7 +64,7 @@ sub map_javascript { [ '/js/map-wmts-base.js', '/js/map-wmts-bristol.js', '/cobrands/fixmystreet/assets.js', - '/cobrands/bristol/js.js', + '/cobrands/bristol/assets.js', ] } # Reproject a WGS84 lat/lon into BNG easting/northing diff --git a/perllib/FixMyStreet/Map/Bromley.pm b/perllib/FixMyStreet/Map/Bromley.pm index 22e4147f6..cd50cc1d1 100644 --- a/perllib/FixMyStreet/Map/Bromley.pm +++ b/perllib/FixMyStreet/Map/Bromley.pm @@ -10,12 +10,13 @@ use base 'FixMyStreet::Map::FMS'; use strict; sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.buckinghamshire.js', + '/vendor/OpenLayers/OpenLayers.wfs.js', '/js/map-OpenLayers.js', '/js/map-bing-ol.js', '/js/map-fms.js', '/cobrands/fixmystreet/assets.js', '/cobrands/bromley/map.js', + '/cobrands/bromley/assets.js', ] } sub map_tile_base { diff --git a/perllib/FixMyStreet/Map/Buckinghamshire.pm b/perllib/FixMyStreet/Map/Buckinghamshire.pm index b6d86d4b9..10ee2a080 100644 --- a/perllib/FixMyStreet/Map/Buckinghamshire.pm +++ b/perllib/FixMyStreet/Map/Buckinghamshire.pm @@ -7,13 +7,14 @@ use base 'FixMyStreet::Map::OSM'; use strict; sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.buckinghamshire.js', + '/vendor/OpenLayers/OpenLayers.wfs.js', '/vendor/OpenLayers.Projection.OrdnanceSurvey.js', '/js/map-OpenLayers.js', '/js/map-OpenStreetMap.js', '/cobrands/fixmystreet-uk-councils/roadworks.js', '/cobrands/fixmystreet/assets.js', '/cobrands/buckinghamshire/js.js', + '/cobrands/buckinghamshire/assets.js', ] } 1; diff --git a/perllib/FixMyStreet/Map/FMS.pm b/perllib/FixMyStreet/Map/FMS.pm index 13c7f9d87..126fc34bf 100644 --- a/perllib/FixMyStreet/Map/FMS.pm +++ b/perllib/FixMyStreet/Map/FMS.pm @@ -12,14 +12,14 @@ use strict; sub map_template { 'fms' } sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/vendor/OpenLayers/OpenLayers.wfs.js', '/js/map-OpenLayers.js', '/js/map-bing-ol.js', '/js/map-fms.js', ] } sub map_tile_base { - '-', "//%stilma.mysociety.org/sv/%d/%d/%d.png"; + '-', "//%stilma.mysociety.org/oml/%d/%d/%d.png"; } sub map_tiles { @@ -36,8 +36,8 @@ sub map_tiles { ]; } else { my $key = FixMyStreet->config('BING_MAPS_API_KEY'); - my $url = "g=5941"; - $url .= "&productSet=mmOS&key=$key" if $z > 10 && !$ni; + my $url = "g=6570"; + $url .= "&productSet=mmOS&key=$key" if $z > 11 && !$ni; return [ "//ecn.t0.tiles.virtualearth.net/tiles/r" . $self->get_quadkey($x-1, $y-1, $z) . ".png?$url", "//ecn.t1.tiles.virtualearth.net/tiles/r" . $self->get_quadkey($x, $y-1, $z) . ".png?$url", diff --git a/perllib/FixMyStreet/Map/Google.pm b/perllib/FixMyStreet/Map/Google.pm index f40eff167..c1fb05e43 100644 --- a/perllib/FixMyStreet/Map/Google.pm +++ b/perllib/FixMyStreet/Map/Google.pm @@ -44,6 +44,7 @@ sub display_map { if defined $c->get_param('lat'); $params{longitude} = Utils::truncate_coordinate($c->get_param('lon') + 0) if defined $c->get_param('lon'); + $params{zoomToBounds} = $params{any_zoom} && !defined $c->get_param('zoom'); my $zoom = defined $c->get_param('zoom') ? $c->get_param('zoom') + 0 : $default_zoom; $zoom = $numZoomLevels - 1 if $zoom >= $numZoomLevels; diff --git a/perllib/FixMyStreet/Map/Lincolnshire.pm b/perllib/FixMyStreet/Map/Lincolnshire.pm new file mode 100644 index 000000000..7dbfe5d8e --- /dev/null +++ b/perllib/FixMyStreet/Map/Lincolnshire.pm @@ -0,0 +1,21 @@ +# FixMyStreet:Map::Lincolnshire +# More JavaScript, for street assets + +package FixMyStreet::Map::Lincolnshire; +use base 'FixMyStreet::Map::FMS'; + +use strict; + +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.wfs.js', + '/vendor/OpenLayers.Projection.OrdnanceSurvey.js', + '/js/map-OpenLayers.js', + '/js/map-bing-ol.js', + '/js/map-fms.js', + '/cobrands/fixmystreet-uk-councils/roadworks.js', + '/cobrands/fixmystreet/assets.js', + '/cobrands/lincolnshire/roadworks.js', + '/cobrands/lincolnshire/assets.js', +] } + +1; diff --git a/perllib/FixMyStreet/Map/OSM.pm b/perllib/FixMyStreet/Map/OSM.pm index 47d6eeee7..a6cb6acea 100644 --- a/perllib/FixMyStreet/Map/OSM.pm +++ b/perllib/FixMyStreet/Map/OSM.pm @@ -19,7 +19,7 @@ sub map_type { 'OpenLayers.Layer.OSM.Mapnik' } sub map_template { 'osm' } sub map_javascript { [ - '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/vendor/OpenLayers/OpenLayers.wfs.js', '/js/map-OpenLayers.js', '/js/map-OpenStreetMap.js', ] } @@ -57,6 +57,7 @@ sub display_map { if defined $c->get_param('lat'); $params{longitude} = Utils::truncate_coordinate($c->get_param('lon') + 0) if defined $c->get_param('lon'); + $params{zoomToBounds} = $params{any_zoom} && !defined $c->get_param('zoom'); my %data; $data{cobrand} = $c->cobrand; @@ -69,8 +70,8 @@ sub display_map { sub generate_map_data { my ($self, $data, %params) = @_; - my $numZoomLevels = ZOOM_LEVELS; - my $zoomOffset = MIN_ZOOM_LEVEL; + my $numZoomLevels = $self->ZOOM_LEVELS; + my $zoomOffset = $self->MIN_ZOOM_LEVEL; if ($params{any_zoom}) { $numZoomLevels = 19; $zoomOffset = 0; diff --git a/perllib/FixMyStreet/Map/WMTSBase.pm b/perllib/FixMyStreet/Map/WMTSBase.pm index 960a58a41..051f8f369 100644 --- a/perllib/FixMyStreet/Map/WMTSBase.pm +++ b/perllib/FixMyStreet/Map/WMTSBase.pm @@ -205,15 +205,15 @@ sub get_map_hash { numZoomLevels => $self->zoom_parameters->{zoom_levels}, tile_size => $self->tile_parameters->{size}, tile_dpi => $self->tile_parameters->{dpi}, - tile_urls => encode_json $self->tile_parameters->{urls}, + tile_urls => encode_json( $self->tile_parameters->{urls} ), tile_suffix => $self->tile_parameters->{suffix}, - layer_names => encode_json $self->tile_parameters->{layer_names}, + layer_names => encode_json( $self->tile_parameters->{layer_names} ), layer_style => $self->tile_parameters->{layer_style}, matrix_set => $self->tile_parameters->{matrix_set}, map_projection => $self->tile_parameters->{projection}, origin_x => force_float_format($self->tile_parameters->{origin_x}), origin_y => force_float_format($self->tile_parameters->{origin_y}), - scales => encode_json \@scales, + scales => encode_json( \@scales ), }; } diff --git a/perllib/FixMyStreet/Map/Zurich.pm b/perllib/FixMyStreet/Map/Zurich.pm index 6d9a309ff..857d8a826 100644 --- a/perllib/FixMyStreet/Map/Zurich.pm +++ b/perllib/FixMyStreet/Map/Zurich.pm @@ -22,8 +22,8 @@ sub tile_parameters { my $self = shift; my $params = { urls => [ - 'http://www.ogc.stadt-zuerich.ch/mapproxy/wmts/', - 'http://www.ogc.stadt-zuerich.ch/mapproxy/wmts/', + 'https://www.ogc.stadt-zuerich.ch/mapproxy/wmts/', + 'https://www.ogc.stadt-zuerich.ch/mapproxy/wmts/', ], layer_names => [ 'LuftbildHybrid', 'Stadtplan3D' ], wmts_version => '1.0.0', diff --git a/perllib/FixMyStreet/MapIt.pm b/perllib/FixMyStreet/MapIt.pm new file mode 100644 index 000000000..d0a5f4760 --- /dev/null +++ b/perllib/FixMyStreet/MapIt.pm @@ -0,0 +1,18 @@ +package FixMyStreet::MapIt; + +use FixMyStreet; +use mySociety::MaPit; + +sub call { + my ($url, $params, %opts) = @_; + + # 'area' always returns the ID you provide, no matter its generation, so no + # point in specifying it for that. 'areas' similarly if given IDs, but we + # might be looking up types or names, so might as well specify it then. + $opts{generation} = FixMyStreet->config('MAPIT_GENERATION') + if $url ne 'area' && FixMyStreet->config('MAPIT_GENERATION'); + + return mySociety::MaPit::call($url, $params, %opts); +} + +1; diff --git a/perllib/FixMyStreet/PhotoStorage.pm b/perllib/FixMyStreet/PhotoStorage.pm new file mode 100644 index 000000000..a441fb718 --- /dev/null +++ b/perllib/FixMyStreet/PhotoStorage.pm @@ -0,0 +1,41 @@ +package FixMyStreet::PhotoStorage; + +use Moose; +use Digest::SHA qw(sha1_hex); +use Module::Load; +use FixMyStreet; + +our $instance; # our, so tests can set to undef when testing different backends +sub backend { + return $instance if $instance; + my $class = 'FixMyStreet::PhotoStorage::'; + $class .= FixMyStreet->config('PHOTO_STORAGE_BACKEND') || 'FileSystem'; + load $class; + $instance = $class->new(); + return $instance; +} + +sub detect_type { + my ($self, $photo) = @_; + return 'jpeg' if $photo =~ /^\x{ff}\x{d8}/; + return 'png' if $photo =~ /^\x{89}\x{50}/; + return 'tiff' if $photo =~ /^II/; + return 'gif' if $photo =~ /^GIF/; + return ''; +} + +=head2 get_fileid + +Calculates an identifier for a binary blob of photo data. +This is just the SHA1 hash of the blob currently. + +=cut + +sub get_fileid { + my ($self, $photo_blob) = @_; + return sha1_hex($photo_blob); +} + + + +1; diff --git a/perllib/FixMyStreet/PhotoStorage/FileSystem.pm b/perllib/FixMyStreet/PhotoStorage/FileSystem.pm new file mode 100644 index 000000000..1d3fe5cfd --- /dev/null +++ b/perllib/FixMyStreet/PhotoStorage/FileSystem.pm @@ -0,0 +1,112 @@ +package FixMyStreet::PhotoStorage::FileSystem; + +use Moose; +use parent 'FixMyStreet::PhotoStorage'; + +use Path::Tiny 'path'; + + +has upload_dir => ( + is => 'ro', + lazy => 1, + default => sub { + my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS'); + my $dir = $cfg ? $cfg->{UPLOAD_DIR} : FixMyStreet->config('UPLOAD_DIR'); + return path($dir)->absolute(FixMyStreet->path_to()); + }, +); + +=head2 init + +Creates UPLOAD_DIR and checks it's writeable. + +=cut + +sub init { + my $self = shift; + my $cache_dir = $self->upload_dir; + $cache_dir->mkpath; + unless ( -d $cache_dir && -w $cache_dir ) { + warn "\x1b[31mCan't find/write to photo cache directory '$cache_dir'\x1b[0m\n"; + return; + } + return 1; +} + +=head2 get_file + +Returns a Path::Tiny path to a file on disk identified by an ID and type. +File may or may not exist. This handle is then used to read photo data or +write to disk. + +=cut + +sub get_file { + my ($self, $fileid, $type) = @_; + my $cache_dir = $self->upload_dir; + return path( $cache_dir, "$fileid.$type" ); +} + + +=head2 store_photo + +Stores a blob of binary data representing a photo on disk. +Returns a key which is used in the future to get the contents of the file. + +=cut + +sub store_photo { + my ($self, $photo_blob) = @_; + + my $type = $self->detect_type($photo_blob) || 'jpeg'; + my $fileid = $self->get_fileid($photo_blob); + my $file = $self->get_file($fileid, $type); + $file->spew_raw($photo_blob); + + return $file->basename; +} + + +=head2 retrieve_photo + +Fetches the file content of a particular photo from storage. +Returns the binary blob, the filetype, and the file path, if +the photo exists in storage. + +=cut + +sub retrieve_photo { + my ($self, $filename) = @_; + + my ($fileid, $type) = split /\./, $filename; + my $file = $self->get_file($fileid, $type); + if ($file->exists) { + my $photo = $file->slurp_raw; + return ($photo, $type, $file); + } +} + + +=head2 validate_key + +A long-running FMS instance might have reports whose photo IDs in the DB +don't include the file extension. This function takes a value from the DB and +returns a 'tidied' version that can be used when calling photo_exists +or retrieve_photo. + +If the passed key doesn't seem like it'll result in a valid filename (i.e. +it's not a 40-char SHA1 hash) returns undef. + +=cut + +sub validate_key { + my ($self, $key) = @_; + + my ($fileid, $type) = split /\./, $key; + $type ||= 'jpeg'; + if ($fileid && length($fileid) == 40) { + return "$fileid.$type"; + } +} + +1; diff --git a/perllib/FixMyStreet/PhotoStorage/S3.pm b/perllib/FixMyStreet/PhotoStorage/S3.pm new file mode 100644 index 000000000..45325e9dc --- /dev/null +++ b/perllib/FixMyStreet/PhotoStorage/S3.pm @@ -0,0 +1,122 @@ +package FixMyStreet::PhotoStorage::S3; + +use Moose; +use parent 'FixMyStreet::PhotoStorage'; + +use Net::Amazon::S3; +use Try::Tiny; + + +has client => ( + is => 'ro', + lazy => 1, + default => sub { + my $key = FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{ACCESS_KEY}; + my $secret = FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{SECRET_KEY}; + + my $s3 = Net::Amazon::S3->new( + aws_access_key_id => $key, + aws_secret_access_key => $secret, + retry => 1, + ); + return Net::Amazon::S3::Client->new( s3 => $s3 ); + }, +); + +has bucket => ( + is => 'ro', + lazy => 1, + default => sub { + shift->client->bucket( name => FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{BUCKET} ); + }, +); + +has region => ( + is => 'ro', + lazy => 1, + default => sub { + return FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{REGION}; + }, +); + +has prefix => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $prefix = FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{PREFIX}; + return "" unless $prefix; + $prefix =~ s#/$##; + return "$prefix/"; + }, +); + +sub init { + my $self = shift; + + return 1 if $self->_bucket_exists(); + + if ( FixMyStreet->config('PHOTO_STORAGE_OPTIONS')->{CREATE_BUCKET} ) { + my $name = $self->bucket->name; + try { + $self->client->create_bucket( + name => $name, + location_constraint => $self->region, + ); + } catch { + warn "\x1b[31mCouldn't create S3 bucket '$name'\x1b[0m\n"; + return; + }; + + return 1 if $self->_bucket_exists(); + + warn "\x1b[31mCouldn't create S3 bucket '$name'\x1b[0m\n"; + return; + } else { + my $bucket = $self->bucket->name; + warn "\x1b[31mS3 bucket '$bucket' doesn't exist and CREATE_BUCKET is not set.\x1b[0m\n"; + return; + } +} + +sub _bucket_exists { + my $self = shift; + my $name = $self->bucket->name; + my @buckets = $self->client->buckets; + return grep { $_->name eq $name } @buckets; +} + +sub get_object { + my ($self, $key) = @_; + return $self->bucket->object( key => $key ); +} + +sub store_photo { + my ($self, $photo_blob) = @_; + + my $type = $self->detect_type($photo_blob) || 'jpeg'; + my $fileid = $self->get_fileid($photo_blob); + my $key = $self->prefix . "$fileid.$type"; + + my $object = $self->get_object($key); + $object->put($photo_blob); + + return $key; +} + + +sub retrieve_photo { + my ($self, $key) = @_; + + my $object = $self->get_object($key); + if ($object->exists) { + my ($fileid, $type) = split /\./, $key; + return ($object->get, $type); + } + +} + +sub validate_key { $_[1] } + + +1; diff --git a/perllib/FixMyStreet/Roles/Abuser.pm b/perllib/FixMyStreet/Roles/Abuser.pm index e2e9eb19e..7510e6bc2 100644 --- a/perllib/FixMyStreet/Roles/Abuser.pm +++ b/perllib/FixMyStreet/Roles/Abuser.pm @@ -14,7 +14,8 @@ sub is_from_abuser { my $self = shift; my $email = $self->user->email; - my ($domain) = $email =~ m{ @ (.*) \z }x if $email; + my $domain; + ($domain) = $email =~ m{ @ (.*) \z }x if $email; my $phone = $self->user->phone; # search for an entry in the abuse table diff --git a/perllib/FixMyStreet/Roles/ConfirmValidation.pm b/perllib/FixMyStreet/Roles/ConfirmValidation.pm new file mode 100644 index 000000000..776230287 --- /dev/null +++ b/perllib/FixMyStreet/Roles/ConfirmValidation.pm @@ -0,0 +1,38 @@ +package FixMyStreet::Roles::ConfirmValidation; +use Moo::Role; + +=head1 NAME + +FixMyStreet::Roles::ConfirmValidation - role for adding standard confirm validation + +=head1 SYNOPSIS + +This is applied to a Cobrand class to add validation of reports using standard +Confirm field lengths. + + use Moo; + with 'FixMyStreet::Roles::ConfirmValidation'; + +=cut + +has max_report_length => ( is => 'ro', default => 2000 ); + +sub report_validation { + my ($self, $report, $errors) = @_; + + if ( length( $report->name ) > 50 ) { + $errors->{name} = sprintf( _('Names are limited to %d characters in length.'), 50 ); + } + + if ( length( $report->user->phone ) > 20 ) { + $errors->{phone} = sprintf( _('Phone numbers are limited to %s characters in length.'), 20 ); + } + + if ( length( $report->detail ) > $self->max_report_length ) { + $errors->{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), $self->max_report_length ); + } + + return $errors; +} + +1; diff --git a/perllib/FixMyStreet/Roles/Moderation.pm b/perllib/FixMyStreet/Roles/Moderation.pm new file mode 100644 index 000000000..fb9ea3a70 --- /dev/null +++ b/perllib/FixMyStreet/Roles/Moderation.pm @@ -0,0 +1,47 @@ +package FixMyStreet::Roles::Moderation; +use Moo::Role; + +=head2 latest_moderation_log_entry + +Return most recent AdminLog object concerning moderation + +=cut + +sub latest_moderation { + my $self = shift; + + return $self->moderation_original_datas->search( + $self->moderation_filter, + { order_by => { -desc => 'id' } })->first; +} + +sub latest_moderation_log_entry { + my $self = shift; + + my $latest = $self->latest_moderation; + return unless $latest; + + my $rs = $self->result_source->schema->resultset("AdminLog"); + my $log = $rs->search({ + object_id => $latest->id, + object_type => 'moderation', + })->first; + return $log if $log; + + return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => { -desc => 'id' } })->first; +} + +=head2 moderation_history + +Returns all moderation history, most recent first. + +=cut + +sub moderation_history { + my $self = shift; + return $self->moderation_original_datas->search( + $self->moderation_filter, + { order_by => { -desc => 'id' } })->all; +} + +1; diff --git a/perllib/FixMyStreet/Roles/PhotoSet.pm b/perllib/FixMyStreet/Roles/PhotoSet.pm index 2a6863cff..4a40ef3f9 100644 --- a/perllib/FixMyStreet/Roles/PhotoSet.pm +++ b/perllib/FixMyStreet/Roles/PhotoSet.pm @@ -19,9 +19,8 @@ Return a PhotoSet object for all photos attached to this field sub get_photoset { my ($self) = @_; - my $class = 'FixMyStreet::App::Model::PhotoSet'; - eval "use $class"; - return $class->new({ + require FixMyStreet::App::Model::PhotoSet; + return FixMyStreet::App::Model::PhotoSet->new({ db_data => $self->photo, object => $self, }); diff --git a/perllib/FixMyStreet/Script/Alerts.pm b/perllib/FixMyStreet/Script/Alerts.pm index 4b5641f9e..55f4b3db5 100644 --- a/perllib/FixMyStreet/Script/Alerts.pm +++ b/perllib/FixMyStreet/Script/Alerts.pm @@ -8,7 +8,7 @@ use IO::String; use FixMyStreet::Gaze; use mySociety::Locale; -use mySociety::MaPit; +use FixMyStreet::MapIt; use RABX; use FixMyStreet::Cobrand; @@ -185,12 +185,15 @@ sub send() { # Get a report object for its photo and static map $data{report} = $schema->resultset('Problem')->find({ id => $row->{id} }); } - if ($ref eq 'area_problems' || $ref eq 'council_problems' || $ref eq 'ward_problems') { - my $va_info = mySociety::MaPit::call('area', $row->{alert_parameter}); + if ($ref eq 'area_problems') { + my $va_info = FixMyStreet::MapIt::call('area', $row->{alert_parameter}); $data{area_name} = $va_info->{name}; + } elsif ($ref eq 'council_problems' || $ref eq 'ward_problems') { + my $body = FixMyStreet::DB->resultset('Body')->find({ id => $row->{alert_parameter} }); + $data{area_name} = $body->name; } if ($ref eq 'ward_problems') { - my $va_info = mySociety::MaPit::call('area', $row->{alert_parameter2}); + my $va_info = FixMyStreet::MapIt::call('area', $row->{alert_parameter2}); $data{ward_name} = $va_info->{name}; } } diff --git a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm index 03bc511a0..0c938682d 100644 --- a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm +++ b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm @@ -1,11 +1,8 @@ package FixMyStreet::Script::ArchiveOldEnquiries; -use strict; +use v5.14; use warnings; -require 5.8.0; -use FixMyStreet; -use FixMyStreet::App; use FixMyStreet::DB; use FixMyStreet::Cobrand; use FixMyStreet::Map; @@ -17,17 +14,17 @@ my $opts = { }; sub query { - return { - bodies_str => { 'LIKE', "%".$opts->{body}."%"}, - -and => [ + my $rs = shift; + return $rs->to_body($opts->{body})->search({ + -and => [ lastupdate => { '<', $opts->{email_cutoff} }, lastupdate => { '>', $opts->{closure_cutoff} }, ], - state => [ FixMyStreet::DB::Result::Problem->open_states() ], - }; + state => [ FixMyStreet::DB::Result::Problem->open_states() ], + }); } -sub archive { +sub update_options { my $params = shift; if ( $params ) { $opts = { @@ -35,13 +32,19 @@ sub archive { %$params, }; } +} + +sub archive { + my $params = shift; + update_options($params); unless ( $opts->{commit} ) { printf "Doing a dry run; emails won't be sent and reports won't be closed.\n"; printf "Re-run with --commit to actually archive reports.\n\n"; } - my @user_ids = FixMyStreet::DB->resultset('Problem')->search(query(), + my $rs = FixMyStreet::DB->resultset('Problem'); + my @user_ids = query($rs)->search(undef, { distinct => 1, columns => ['user_id'], @@ -55,7 +58,7 @@ sub archive { }); my $user_count = $users->count; - my $problem_count = FixMyStreet::DB->resultset('Problem')->search(query(), + my $problem_count = query($rs)->search(undef, { columns => ['id'], rows => $opts->{limit}, @@ -71,8 +74,7 @@ sub archive { } } - my $problems_to_close = FixMyStreet::DB->resultset('Problem')->search({ - bodies_str => { 'LIKE', "%".$opts->{body}."%"}, + my $problems_to_close = $rs->to_body($opts->{body})->search({ lastupdate => { '<', $opts->{closure_cutoff} }, state => [ FixMyStreet::DB::Result::Problem->open_states() ], }, { @@ -87,7 +89,8 @@ sub archive { sub send_email_and_close { my ($user) = @_; - my $problems = $user->problems->search(query(), { + my $problems = $user->problems; + $problems = query($problems)->search(undef, { order_by => { -desc => 'confirmed' }, }); @@ -135,22 +138,36 @@ sub close_problems { return unless $opts->{commit}; my $problems = shift; + + my $extra = { auto_closed_by_script => 1 }; + $extra->{is_superuser} = 1 if !$opts->{user_name}; + + my $cobrand; while (my $problem = $problems->next) { + # need to do this in case no reports were closed with an + # email in which case we won't have set the lang and domain + if ($opts->{cobrand} && !$cobrand) { + $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($opts->{cobrand})->new(); + $cobrand->set_lang_and_domain($problem->lang, 1); + } + my $timestamp = \'current_timestamp'; my $comment = $problem->add_to_comments( { - text => '', + text => $opts->{closure_text} || '', created => $timestamp, confirmed => $timestamp, user_id => $opts->{user}, - name => _('an administrator'), + name => $opts->{user_name} || _('an administrator'), mark_fixed => 0, anonymous => 0, state => 'confirmed', problem_state => 'closed', - extra => { is_superuser => 1 }, + extra => $extra, } ); $problem->update({ state => 'closed', send_questionnaire => 0 }); + next if $opts->{retain_alerts}; + # Stop any alerts being sent out about this closure. my @alerts = FixMyStreet::DB->resultset('Alert')->search( { alert_type => 'new_updates', diff --git a/perllib/FixMyStreet/Script/Questionnaires.pm b/perllib/FixMyStreet/Script/Questionnaires.pm index 5fc01512d..aab4b9b75 100644 --- a/perllib/FixMyStreet/Script/Questionnaires.pm +++ b/perllib/FixMyStreet/Script/Questionnaires.pm @@ -43,14 +43,19 @@ sub send_questionnaires_period { while (my $row = $unsent->next) { - my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($row->cobrand)->new(); + my $cobrand = $row->get_cobrand_logged; $cobrand->set_lang_and_domain($row->lang, 1); FixMyStreet::Map::set_map_class($cobrand->map_type); # Not all cobrands send questionnaires next unless $cobrand->send_questionnaires; - if ($row->is_from_abuser || !$row->user->email_verified) { + # Cobrands can also override sending per row if they wish + my $cobrand_send = $cobrand->call_hook('send_questionnaire', $row) // 1; + + if ($row->is_from_abuser || !$row->user->email_verified || + !$cobrand_send || $row->is_closed + ) { $row->update( { send_questionnaire => 0 } ); next; } diff --git a/perllib/FixMyStreet/Script/Reports.pm b/perllib/FixMyStreet/Script/Reports.pm index 578d966d6..ecd461cd9 100644 --- a/perllib/FixMyStreet/Script/Reports.pm +++ b/perllib/FixMyStreet/Script/Reports.pm @@ -8,7 +8,6 @@ use DateTime::Format::Pg; use Utils; use Utils::OpenStreetMap; -use mySociety::MaPit; use FixMyStreet; use FixMyStreet::Cobrand; @@ -44,7 +43,7 @@ sub send(;$) { debug_print("starting to loop through unsent problem reports...") if $debug_mode; while (my $row = $unsent->next) { - my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($row->cobrand)->new(); + my $cobrand = $row->get_cobrand_logged; FixMyStreet::DB->schema->cobrand($cobrand); if ($debug_mode) { @@ -127,10 +126,19 @@ sub send(;$) { $missing = join(' / ', @missing) if @missing; } + my $send_confirmation_email = $cobrand->report_sent_confirmation_email; + my @dear; my %reporters = (); my $skip = 0; while (my $body = $bodies->next) { + # See if this body wants confirmation email (in case report made on national site, for example) + if (my $cobrand_body = $body->get_cobrand_handler) { + if (my $id_ref = $cobrand_body->report_sent_confirmation_email) { + $send_confirmation_email = $id_ref; + } + } + my $sender_info = $cobrand->get_body_sender( $body, $row->category ); my $sender = "FixMyStreet::SendReport::" . $sender_info->{method}; @@ -140,7 +148,9 @@ sub send(;$) { } $reporters{ $sender } ||= $sender->new(); - my $inspection_required = $sender_info->{contact}->get_extra_metadata('inspection_required') if $sender_info->{contact}; + my $inspection_required = $sender_info->{contact} + ? $sender_info->{contact}->get_extra_metadata('inspection_required') + : undef; if ( $inspection_required ) { my $reputation_threshold = $sender_info->{contact}->get_extra_metadata('reputation_threshold') || 0; my $reputation_threshold_met = 0; @@ -211,12 +221,13 @@ sub send(;$) { # Multiply results together, so one success counts as a success. my $result = -1; + my @methods; for my $sender ( keys %reporters ) { debug_print("sending using " . $sender, $row->id) if $debug_mode; $sender = $reporters{$sender}; my $res = $sender->send( $row, \%h ); $result *= $res; - $row->add_send_method($sender) if !$res; + push @methods, $sender if !$res; if ( $sender->unconfirmed_counts) { foreach my $e (keys %{ $sender->unconfirmed_counts } ) { foreach my $c (keys %{ $sender->unconfirmed_counts->{$e} }) { @@ -229,12 +240,19 @@ sub send(;$) { if FixMyStreet->test_mode && $sender->can('open311_test_req_used'); } + # Add the send methods now because e.g. Open311 + # send() calls $row->discard_changes + foreach (@methods) { + $row->add_send_method($_); + } + unless ($result) { $row->update( { whensent => \'current_timestamp', lastupdate => \'current_timestamp', } ); - if ( $cobrand->report_sent_confirmation_email && !$h{anonymous_report}) { + if ($send_confirmation_email && !$h{anonymous_report}) { + $h{sent_confirm_id_ref} = $row->$send_confirmation_email; _send_report_sent_email( $row, \%h, $nomail, $cobrand ); } debug_print("send successful: OK", $row->id) if $debug_mode; @@ -274,7 +292,7 @@ sub send(;$) { } ); while (my $row = $unsent->next) { my $base_url = FixMyStreet->config('BASE_URL'); - $sending_errors .= "* " . $base_url . "/report/" . $row->id . ", failed " + $sending_errors .= "\n" . '=' x 80 . "\n\n" . "* " . $base_url . "/report/" . $row->id . ", failed " . $row->send_fail_count . " times, last at " . $row->send_fail_timestamp . ", reason " . $row->send_fail_reason . "\n"; } @@ -304,7 +322,6 @@ sub _send_report_sent_email { $h, { To => $row->user->email, - From => [ $cobrand->contact_email, $cobrand->contact_name ], }, undef, $nomail, diff --git a/perllib/FixMyStreet/Script/UpdateAllReports.pm b/perllib/FixMyStreet/Script/UpdateAllReports.pm index 21d8d28a0..33665b9da 100755 --- a/perllib/FixMyStreet/Script/UpdateAllReports.pm +++ b/perllib/FixMyStreet/Script/UpdateAllReports.pm @@ -99,13 +99,14 @@ sub generate { } sub end_period { - my $period = shift; - FixMyStreet->set_time_zone(DateTime->now)->truncate(to => $period)->add($period . 's' => 1)->subtract(seconds => 1); + my ($period, $end) = @_; + $end ||= DateTime->now; + FixMyStreet->set_time_zone($end)->truncate(to => $period)->add($period . 's' => 1)->subtract(seconds => 1); } sub loop_period { - my ($date, $period, $extra) = @_; - my $end = end_period($period); + my ($date, $extra, $period, $end) = @_; + $end = end_period($period, $end); my @out; while ($date <= $end) { push @out, { n => $date->$period, $extra ? (d => $date->$extra) : () }; @@ -114,6 +115,21 @@ sub loop_period { return @out; } +sub get_period_group { + my ($start, $end) = @_; + my ($group_by, $extra); + if (DateTime::Duration->compare($end - $start, DateTime::Duration->new(months => 1)) < 0) { + $group_by = 'day'; + } elsif (DateTime::Duration->compare($end - $start, DateTime::Duration->new(years => 1)) < 0) { + $group_by = 'month'; + $extra = 'month_abbr'; + } else { + $group_by = 'year'; + } + + return ($group_by, $extra); +} + sub generate_dashboard { my $body = shift; @@ -138,16 +154,8 @@ sub generate_dashboard { $min_confirmed = FixMyStreet->set_time_zone(DateTime->now)->truncate(to => 'day'); } - my ($group_by, $extra); - if (DateTime::Duration->compare($end_today - $min_confirmed, DateTime::Duration->new(months => 1)) < 0) { - $group_by = 'day'; - } elsif (DateTime::Duration->compare($end_today - $min_confirmed, DateTime::Duration->new(years => 1)) < 0) { - $group_by = 'month'; - $extra = 'month_abbr'; - } else { - $group_by = 'year'; - } - my @problem_periods = loop_period($min_confirmed, $group_by, $extra); + my ($group_by, $extra) = get_period_group($min_confirmed, $end_today); + my @problem_periods = loop_period($min_confirmed, $extra, $group_by); my %problems_reported_by_period = stuff_by_day_or_year( $group_by, $rs, @@ -261,7 +269,7 @@ sub calculate_top_five_bodies { my $bodies = FixMyStreet::DB->resultset('Body')->search; while (my $body = $bodies->next) { - my $avg = $body->calculate_average; + my $avg = $body->calculate_average($cobrand_cls->call_hook("body_responsiveness_threshold")); push @top_five_bodies, { name => $body->name, days => int($avg / 60 / 60 / 24 + 0.5) } if defined $avg; } diff --git a/perllib/FixMyStreet/SendReport.pm b/perllib/FixMyStreet/SendReport.pm index 2739e3043..db95850e6 100644 --- a/perllib/FixMyStreet/SendReport.pm +++ b/perllib/FixMyStreet/SendReport.pm @@ -6,6 +6,7 @@ use MooX::Types::MooseLike::Base qw(:all); use Module::Pluggable sub_name => 'senders', search_path => __PACKAGE__, + except => 'FixMyStreet::SendReport::Email::SingleBodyOnly', require => 1; has 'body_config' => ( is => 'rw', isa => HashRef, default => sub { {} } ); diff --git a/perllib/FixMyStreet/SendReport/Angus.pm b/perllib/FixMyStreet/SendReport/Angus.pm deleted file mode 100644 index 4ba5f3070..000000000 --- a/perllib/FixMyStreet/SendReport/Angus.pm +++ /dev/null @@ -1,167 +0,0 @@ -package FixMyStreet::SendReport::Angus; - -use Moo; - -BEGIN { extends 'FixMyStreet::SendReport'; } - -use Try::Tiny; -use Encode; -use XML::Simple; - -sub get_auth_token { - my ($self, $authxml) = @_; - - my $xml = new XML::Simple; - my $obj; - - eval { - $obj = $xml->parse_string( $authxml ); - }; - - my $success = $obj->{success}; - $success =~ s/^\s+|\s+$//g if defined $success; - my $token = $obj->{AuthenticateResult}; - $token =~ s/^\s+|\s+$//g if defined $token; - - if (defined $success && $success eq 'True' && defined $token) { - return $token; - } else { - $self->error("Couldn't authenticate against Angus endpoint."); - } -} - -sub get_external_id { - my ($self, $resultxml) = @_; - - my $xml = new XML::Simple; - my $obj; - - eval { - $obj = $xml->parse_string( $resultxml ); - }; - - my $success = $obj->{success}; - $success =~ s/^\s+|\s+$//g if defined $success; - my $external_id = $obj->{CreateRequestResult}->{RequestId}; - - if (defined $success && $success eq 'True' && defined $external_id) { - return $external_id; - } else { - $self->error("Couldn't find external id in response from Angus endpoint."); - return undef; - } -} - -sub crm_request_type { - my ($self, $row, $h) = @_; - return 'StLight'; # TODO: Set this according to report category -} - -sub jadu_form_fields { - my ($self, $row, $h) = @_; - my $xml = XML::Simple->new( - NoAttr=> 1, - KeepRoot => 1, - SuppressEmpty => 0, - ); - my $metas = $row->get_extra_fields(); - my %extras; - foreach my $field (@$metas) { - $extras{$field->{name}} = $field->{value}; - } - my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($row->cobrand)->new(); - my $output = $xml->XMLout({ - formfields => { - formfield => [ - { - name => 'RequestTitle', - value => $h->{title} - }, - { - name => 'RequestDetails', - value => $h->{detail} - }, - { - name => 'ReporterName', - value => $h->{name} - }, - { - name => 'ReporterEmail', - value => $h->{email} - }, - { - name => 'ReporterAnonymity', - value => $row->anonymous ? 'True' : 'False' - }, - { - name => 'ReportedDateTime', - value => $h->{confirmed} - }, - { - name => 'ColumnId', - value => $extras{'column_id'} || '' - }, - { - name => 'ReportId', - value => $h->{id} - }, - { - name => 'ReportedNorthing', - value => $h->{northing} - }, - { - name => 'ReportedEasting', - value => $h->{easting} - }, - { - name => 'Imageurl1', - value => $row->photos->[0] ? ($cobrand->base_url . $row->photos->[0]->{url_full}) : '' - }, - { - name => 'Imageurl2', - value => $row->photos->[1] ? ($cobrand->base_url . $row->photos->[1]->{url_full}) : '' - }, - { - name => 'Imageurl3', - value => $row->photos->[2] ? ($cobrand->base_url . $row->photos->[2]->{url_full}) : '' - } - ] - } - }); - # The endpoint crashes if the JADUFormFields string has whitespace between XML elements, so strip it out... - $output =~ s/>[\s\n]+</></g; - return $output; -} - -sub send { - my ( $self, $row, $h ) = @_; - - # FIXME: should not recreate this each time - my $angus_service; - - require Integrations::AngusSOAP; - - my $return = 1; - $angus_service ||= Integrations::AngusSOAP->on_fault(sub { my($soap, $res) = @_; die ref $res ? $res->faultstring : $soap->transport->status, "\n"; }); - try { - my $authresult = $angus_service->AuthenticateJADU(); - my $authtoken = $self->get_auth_token( $authresult ); - # authenticationtoken, CallerId, CallerAddressId, DeliveryId, DeliveryAddressId, CRMRequestType, JADUXFormRef, PaymentRef, JADUFormFields - my $result = $angus_service->CreateServiceRequest( - $authtoken, '1', '1', '1', '1', $self->crm_request_type($row, $h), - 'FMS', '', $self->jadu_form_fields($row, $h) - ); - my $external_id = $self->get_external_id( $result ); - if ( $external_id ) { - $row->external_id( $external_id ); - $return = 0; - } - } catch { - my $e = $_; - $self->error( "Error sending to Angus: $e" ); - }; - $self->success( !$return ); - return $return; -} - -1; diff --git a/perllib/FixMyStreet/SendReport/Blackhole.pm b/perllib/FixMyStreet/SendReport/Blackhole.pm new file mode 100644 index 000000000..2c1a4fc8e --- /dev/null +++ b/perllib/FixMyStreet/SendReport/Blackhole.pm @@ -0,0 +1,20 @@ +package FixMyStreet::SendReport::Blackhole; + +use Moo; + +BEGIN { extends 'FixMyStreet::SendReport'; } + +=head2 send + +Immediately marks the report as successfully sent, but doesn't actually send +it anywhere. + +=cut + +sub send { + my $self = shift; + $self->success(1); + return 0; +} + +1; diff --git a/perllib/FixMyStreet/SendReport/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm index 583aaaa08..cd697fa0f 100644 --- a/perllib/FixMyStreet/SendReport/Email.pm +++ b/perllib/FixMyStreet/SendReport/Email.pm @@ -19,6 +19,9 @@ sub build_recipient_list { my ($body_email, $state, $note) = ( $contact->email, $contact->state, $contact->note ); + $body_email = swandt_contact($row->latitude, $row->longitude) + if ($body->areas->{2427} || $body->areas->{2429}) && $body_email eq 'SPECIAL'; + unless ($state eq 'confirmed') { $all_confirmed = 0; $note = 'Body ' . $row->bodies_str . ' deleted' @@ -57,7 +60,7 @@ sub send { my $self = shift; my ( $row, $h ) = @_; - my $recips = $self->build_recipient_list( $row, $h ); + my $recips = @{$self->to} ? 1 : $self->build_recipient_list( $row, $h ); # on a staging server send emails to ourselves rather than the bodies if (FixMyStreet->staging_flag('send_reports', 0) && !FixMyStreet->test_mode) { @@ -71,7 +74,9 @@ sub send { } my ($verbose, $nomail) = CronFns::options(); - my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($row->cobrand)->new(); + my $cobrand = $row->get_cobrand_logged; + $cobrand = $cobrand->call_hook(get_body_handler_for_problem => $row) || $cobrand; + my $params = { To => $self->to, }; @@ -109,10 +114,19 @@ sub send { return $result; } +# SW&T has different contact addresses depending upon the old district +sub swandt_contact { + my $district = _get_district_for_contact(@_); + my $email; + $email = ['customerservices', 'westsomerset'] if $district == 2427; + $email = ['enquiries', 'tauntondeane'] if $district == 2429; + return join('@', $email->[0], $email->[1] . '.gov.uk'); +} + sub _get_district_for_contact { my ( $lat, $lon ) = @_; my $district = - mySociety::MaPit::call( 'point', "4326/$lon,$lat", type => 'DIS' ); + FixMyStreet::MapIt::call( 'point', "4326/$lon,$lat", type => 'DIS' ); ($district) = keys %$district; return $district; } diff --git a/perllib/FixMyStreet/SendReport/Email/Highways.pm b/perllib/FixMyStreet/SendReport/Email/Highways.pm new file mode 100644 index 000000000..2a1f7b305 --- /dev/null +++ b/perllib/FixMyStreet/SendReport/Email/Highways.pm @@ -0,0 +1,11 @@ +package FixMyStreet::SendReport::Email::Highways; + +use Moo; +extends 'FixMyStreet::SendReport::Email::SingleBodyOnly'; + +has contact => ( + is => 'ro', + default => 'Pothole' +); + +1; diff --git a/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm b/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm new file mode 100644 index 000000000..cf778c549 --- /dev/null +++ b/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm @@ -0,0 +1,28 @@ +package FixMyStreet::SendReport::Email::SingleBodyOnly; + +use Moo; +extends 'FixMyStreet::SendReport::Email'; + +has contact => ( + is => 'ro', + default => sub { die 'Need to override contact' } +); + +sub build_recipient_list { + my ( $self, $row, $h ) = @_; + + return unless @{$self->bodies} == 1; + my $body = $self->bodies->[0]; + + # We don't care what the category was, look up the relevant contact + my $contact = $row->result_source->schema->resultset("Contact")->not_deleted->find({ + body_id => $body->id, + category => $self->contact, + }); + return unless $contact; + + @{$self->to} = map { [ $_, $body->name ] } split /,/, $contact->email; + return 1; +} + +1; diff --git a/perllib/FixMyStreet/SendReport/Email/TfL.pm b/perllib/FixMyStreet/SendReport/Email/TfL.pm new file mode 100644 index 000000000..383df9792 --- /dev/null +++ b/perllib/FixMyStreet/SendReport/Email/TfL.pm @@ -0,0 +1,11 @@ +package FixMyStreet::SendReport::Email::TfL; + +use Moo; +extends 'FixMyStreet::SendReport::Email::SingleBodyOnly'; + +has contact => ( + is => 'ro', + default => 'Traffic lights' +); + +1; diff --git a/perllib/FixMyStreet/SendReport/Open311.pm b/perllib/FixMyStreet/SendReport/Open311.pm index 84aa851ed..a661ff206 100644 --- a/perllib/FixMyStreet/SendReport/Open311.pm +++ b/perllib/FixMyStreet/SendReport/Open311.pm @@ -29,6 +29,7 @@ sub send { use_service_as_deviceid => 0, extended_description => 1, multi_photos => 0, + fixmystreet_body => $body, ); my $cobrand = $body->get_cobrand_handler || $row->get_cobrand_logged; @@ -94,6 +95,8 @@ sub send { $self->error( "Failed to send over Open311\n" ) unless $self->error; $self->error( $self->error . "\n" . $open311->error ); } + + $cobrand->call_hook(open311_post_send => $row, $h); } diff --git a/perllib/FixMyStreet/Template.pm b/perllib/FixMyStreet/Template.pm index 4a9cffecb..9c565114b 100644 --- a/perllib/FixMyStreet/Template.pm +++ b/perllib/FixMyStreet/Template.pm @@ -114,7 +114,7 @@ into <br>s too. sub html_paragraph : Filter('html_para') { my $text = shift; my @paras = split(/(?:\r?\n){2,}/, $text); - s/\r?\n/<br>\n/ for @paras; + s/\r?\n/<br>\n/g for @paras; $text = "<p>\n" . join("\n</p>\n\n<p>\n", @paras) . "</p>\n"; return $text; } diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index c5b72a7cf..3ecb13b6a 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -10,6 +10,7 @@ sub import { Test::More->export_to_level(1); } +use Encode; use Test::WWW::Mechanize::Catalyst 'FixMyStreet::App'; use t::Mock::MapIt; use Test::More; @@ -430,7 +431,7 @@ sub extract_problem_title { $banner = $mech->extract_problem_banner; -Returns the problem title from a problem report page. Returns a hashref with id and text. +Returns the problem title from a problem report page. Returns a hashref with class and text. =cut @@ -438,8 +439,8 @@ sub extract_problem_banner { my $mech = shift; my $result = scraper { - process 'div#side > p.banner', id => '@id', text => 'TEXT'; - process 'div.banner > p', id => '@id', text => 'TEXT'; + process 'div.banner', class => '@class'; + process 'div.banner > p', text => 'TEXT'; } ->scrape( $mech->response ); @@ -536,31 +537,6 @@ sub visible_form_values { return \%params; } -=head2 session_cookie_expiry - - $expiry = $mech->session_cookie_expiry( ); - -Returns the current expiry time for the session cookie. Might be '0' which -indicates it expires at end of browser session. - -=cut - -sub session_cookie_expiry { - my $mech = shift; - - my $cookie_name = 'fixmystreet_app_session'; - my $expires = 'not found'; - - $mech # - ->cookie_jar # - ->scan( sub { $expires = $_[8] if $_[1] eq $cookie_name } ); - - croak "Could not find cookie '$cookie_name'" - if $expires && $expires eq 'not found'; - - return $expires || 0; -} - =head2 get_ok_json $decoded = $mech->get_ok_json( $url ); @@ -705,7 +681,7 @@ sub create_problems_for_body { latitude => '51.5016605453401', longitude => '-0.142497580865087', user_id => $user->id, - photo => $mech->get_photo_data, + photo => '74e3362283b6ef0c48686fb0e161da4043bbcc97.jpeg', }; my %report_params = ( %$default_params, %$params ); @@ -720,15 +696,6 @@ sub create_problems_for_body { return @problems; } -sub get_photo_data { - my $mech = shift; - return $mech->{sample_photo} ||= do { - my $sample_file = FixMyStreet->path_to( 't/app/controller/sample.jpg' ); - $mech->builder->ok( -f "$sample_file", "sample file $sample_file exists" ); - $sample_file->slurp(iomode => '<:raw'); - }; -} - sub create_comment_for_problem { my ( $mech, $problem, $user, $name, $text, $anonymous, $state, $problem_state, $params ) = @_; $params ||= {}; @@ -743,4 +710,21 @@ sub create_comment_for_problem { FixMyStreet::App->model('DB::Comment')->create($params); } + +sub encoded_content { + my $self = shift; + return encode_utf8($self->content); +} + +sub content_as_csv { + my $self = shift; + open my $data_handle, '<', \$self->content; + my $csv = Text::CSV->new({ binary => 1 }); + my @rows; + while (my $row = $csv->getline($data_handle)) { + push @rows, $row; + } + return @rows; +} + 1; |