diff options
Diffstat (limited to 'perllib/FixMyStreet/App/Controller')
26 files changed, 2311 insertions, 1657 deletions
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' ] ); |