diff options
Diffstat (limited to 'perllib/FixMyStreet/App/Controller')
38 files changed, 3312 insertions, 1539 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index 2f4669456..038cba9e5 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -4,20 +4,9 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } -use Path::Class; -use POSIX qw(strftime strcoll); -use Digest::SHA qw(sha1_hex); -use mySociety::EmailUtil qw(is_valid_email is_valid_email_list); -use DateTime::Format::Strptime; +use POSIX qw(strcoll); use List::Util 'first'; -use List::MoreUtils 'uniq'; -use mySociety::ArrayUtils; -use Text::CSV; -use Try::Tiny; - -use FixMyStreet::SendReport; use FixMyStreet::SMS; -use Utils; =head1 NAME @@ -72,57 +61,27 @@ Displays some summary information for the requests. sub index : Path : Args(0) { my ( $self, $c ) = @_; - if ($c->cobrand->moniker eq 'zurich' && $c->stash->{admin_type} ne 'super') { - return $c->cobrand->admin(); + if ($c->cobrand->moniker eq 'zurich') { + if ($c->stash->{admin_type} eq 'super') { + $c->forward('/admin/stats/gather'); + return 1; + } else { + return $c->cobrand->admin(); + } } - $c->forward('/admin/stats/state'); - my @unsent = $c->cobrand->problems->search( { state => [ FixMyStreet::DB::Result::Problem::open_states() ], whensent => undef, bodies_str => { '!=', undef }, + # Ignore very recent ones that probably just haven't been sent yet + confirmed => { '<', \"current_timestamp - '5 minutes'::interval" }, + }, + { + order_by => 'confirmed', } )->all; $c->stash->{unsent_reports} = \@unsent; - my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction ); - - my %alert_counts = - map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all; - - $alert_counts{0} ||= 0; - $alert_counts{1} ||= 0; - - $c->stash->{alerts} = \%alert_counts; - - my $contacts = $c->model('DB::Contact')->summary_count(); - - my %contact_counts = - map { $_->state => $_->get_column('state_count') } $contacts->all; - - $contact_counts{confirmed} ||= 0; - $contact_counts{unconfirmed} ||= 0; - $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed}; - - $c->stash->{contacts} = \%contact_counts; - - my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction ); - - my %questionnaire_counts = map { - $_->get_column('answered') => $_->get_column('questionnaire_count') - } $questionnaires->all; - $questionnaire_counts{1} ||= 0; - $questionnaire_counts{0} ||= 0; - - $questionnaire_counts{total} = - $questionnaire_counts{0} + $questionnaire_counts{1}; - $c->stash->{questionnaires_pc} = - $questionnaire_counts{total} - ? sprintf( '%.1f', - $questionnaire_counts{1} / $questionnaire_counts{total} * 100 ) - : _('n/a'); - $c->stash->{questionnaires} = \%questionnaire_counts; - $c->forward('fetch_all_bodies'); return 1; @@ -143,47 +102,38 @@ sub timeline : Path( 'timeline' ) : Args(0) { my %time; - 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; - } + 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 ); + 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; - } + 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; + my $updates = $c->cobrand->updates->timeline; - foreach ($updates->all) { - push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ; - } + foreach ($updates->all) { + push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ; + } - my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction ); + my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction ); - foreach ($alerts->all) { - push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ }; - } + foreach ($alerts->all) { + push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ }; + } - $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction ); + $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction ); - foreach ($alerts->all) { - push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ }; - } - } catch { - die $_; - } finally { - $c->model('DB')->schema->storage->sql_maker->quote_char( '' ); - }; + foreach ($alerts->all) { + push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ }; + } $c->stash->{time} = \%time; @@ -195,7 +145,7 @@ sub fetch_contacts : Private { my $contacts = $c->stash->{body}->contacts->search(undef, { order_by => [ 'category' ] } ); $c->stash->{contacts} = $contacts; - $c->stash->{live_contacts} = $contacts->not_deleted; + $c->stash->{live_contacts} = $contacts->not_deleted_admin; $c->stash->{any_not_confirmed} = $contacts->search({ state => 'unconfirmed' })->count; if ( $c->get_param('text') && $c->get_param('text') eq '1' ) { @@ -221,167 +171,6 @@ sub fetch_languages : Private { return 1; } -sub reports : Path('reports') { - my ( $self, $c ) = @_; - - $c->stash->{edit_body_contacts} = 1 - if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}}; - - my $query = {}; - if ( $c->cobrand->moniker eq 'zurich' ) { - my $type = $c->stash->{admin_type}; - my $body = $c->stash->{body}; - if ( $type eq 'dm' ) { - my @children = map { $_->id } $body->bodies->all; - my @all = (@children, $body->id); - $query = { bodies_str => \@all }; - } elsif ( $type eq 'sdm' ) { - $query = { bodies_str => $body->id }; - } - } - - my $order = $c->get_param('o') || 'created'; - my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1; - $c->stash->{order} = $order; - $c->stash->{dir} = $dir; - $order .= ' desc' if $dir; - - my $p_page = $c->get_param('p') || 1; - my $u_page = $c->get_param('u') || 1; - - 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; - $search_n = int($search) if $search =~ /^\d+$/; - - my $like_search = "%$search%"; - - my $parsed = FixMyStreet::SMS->parse_username($search); - my $valid_phone = $parsed->{phone}; - my $valid_email = $parsed->{email}; - - # when DBIC creates the join it does 'JOIN users user' in the - # SQL which makes PostgreSQL unhappy as user is a reserved - # word. So look up user ID for email separately. - my @user_ids = $c->model('DB::User')->search({ - email => { ilike => $like_search }, - }, { columns => [ 'id' ] } )->all; - @user_ids = map { $_->id } @user_ids; - - my @user_ids_phone = $c->model('DB::User')->search({ - phone => { ilike => $like_search }, - }, { columns => [ 'id' ] } )->all; - @user_ids_phone = map { $_->id } @user_ids_phone; - - if ($valid_email) { - $query->{'-or'} = [ - 'me.user_id' => { -in => \@user_ids }, - ]; - } elsif ($valid_phone) { - $query->{'-or'} = [ - 'me.user_id' => { -in => \@user_ids_phone }, - ]; - } elsif ($search =~ /^id:(\d+)$/) { - $query->{'-or'} = [ - 'me.id' => int($1), - ]; - } elsif ($search =~ /^area:(\d+)$/) { - $query->{'-or'} = [ - 'me.areas' => { like => "%,$1,%" } - ]; - } elsif ($search =~ /^ref:(\d+)$/) { - $query->{'-or'} = [ - 'me.external_id' => { like => "%$1%" } - ]; - } else { - $query->{'-or'} = [ - 'me.id' => $search_n, - 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] }, - 'me.external_id' => { ilike => $like_search }, - 'me.name' => { ilike => $like_search }, - 'me.title' => { ilike => $like_search }, - detail => { ilike => $like_search }, - bodies_str => { like => $like_search }, - cobrand_data => { like => $like_search }, - ]; - } - - my $problems = $c->cobrand->problems->search( - $query, - { - rows => 50, - order_by => [ \"(state='hidden')", \$order ] - } - )->page( $p_page ); - - $c->stash->{problems} = [ $problems->all ]; - $c->stash->{problems_pager} = $problems->pager; - - if ($valid_email) { - $query = [ - 'me.user_id' => { -in => \@user_ids }, - ]; - } elsif ($valid_phone) { - $query = [ - 'me.user_id' => { -in => \@user_ids_phone }, - ]; - } elsif ($search =~ /^id:(\d+)$/) { - $query = [ - 'me.id' => int($1), - 'me.problem_id' => int($1), - ]; - } elsif ($search =~ /^area:(\d+)$/) { - $query = []; - } else { - $query = [ - 'me.id' => $search_n, - 'problem.id' => $search_n, - 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] }, - 'me.name' => { ilike => $like_search }, - text => { ilike => $like_search }, - 'me.cobrand_data' => { ilike => $like_search }, - ]; - } - - if (@$query) { - my $updates = $c->cobrand->updates->search( - { - -or => $query, - }, - { - -select => [ 'me.*', qw/problem.bodies_str problem.state/ ], - prefetch => [qw/problem/], - rows => 50, - order_by => [ \"(me.state='hidden')", \"(problem.state='hidden')", { -desc => 'me.created' } ] - } - )->page( $u_page ); - $c->stash->{updates} = [ $updates->all ]; - $c->stash->{updates_pager} = $updates->pager; - } - - } else { - - my $problems = $c->cobrand->problems->search( - $query, - { order_by => $order, rows => 50 } - )->page( $p_page ); - $c->stash->{problems} = [ $problems->all ]; - $c->stash->{problems_pager} = $problems->pager; - } -} - sub update_user : Private { my ($self, $c, $object) = @_; my $parsed = FixMyStreet::SMS->parse_username($c->get_param('username')); @@ -395,471 +184,6 @@ sub update_user : Private { return 0; } -sub report_edit_display : Private { - my ( $self, $c ) = @_; - - my $problem = $c->stash->{problem}; - - $c->stash->{page} = 'admin'; - FixMyStreet::Map::display_map( - $c, - latitude => $problem->latitude, - longitude => $problem->longitude, - pins => $problem->used_map - ? [ { - latitude => $problem->latitude, - longitude => $problem->longitude, - colour => $c->cobrand->pin_colour($problem, 'admin'), - type => 'big', - draggable => 1, - } ] - : [], - print_report => 1, - ); -} - -sub report_edit : Path('report_edit') : Args(1) { - my ( $self, $c, $id ) = @_; - - my $problem = $c->cobrand->problems->search( { id => $id } )->first; - - $c->detach( '/page_error_404_not_found', [] ) - unless $problem; - - unless ( - $c->cobrand->moniker eq 'zurich' - || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids) - ) { - $c->detach( '/page_error_403_access_denied', [] ); - } - - $c->stash->{problem} = $problem; - if ( $problem->extra ) { - my @fields; - if ( my $fields = $problem->get_extra_fields ) { - for my $field ( @{$fields} ) { - my $name = $field->{description} ? - "$field->{description} ($field->{name})" : - "$field->{name}"; - push @fields, { name => $name, val => $field->{value} }; - } - } - my $extra = $problem->get_extra_metadata; - if ( $extra->{duplicates} ) { - push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) }; - delete $extra->{duplicates}; - } - for my $key ( keys %$extra ) { - push @fields, { name => $key, val => $extra->{$key} }; - } - - $c->stash->{extra_fields} = \@fields; - } - - $c->forward('/auth/get_csrf_token'); - - $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', 'id' ] } ) - ->all ]; - - if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { - $self->rotate_photo($c, $problem, @$rotate_photo_param); - $c->detach('report_edit_display'); - } - - if ( $c->cobrand->moniker eq 'zurich' ) { - my $done = $c->cobrand->admin_report_edit(); - $c->detach('report_edit_display') if $done; - } - - if ( $c->get_param('resend') ) { - $c->forward('/auth/check_csrf_token'); - - $problem->resend; - $problem->update(); - $c->stash->{status_message} = - '<p><em>' . _('That problem will now be resent.') . '</em></p>'; - - $c->forward( 'log_edit', [ $id, 'problem', 'resend' ] ); - } - elsif ( $c->get_param('mark_sent') ) { - $c->forward('/auth/check_csrf_token'); - $problem->update({ whensent => \'current_timestamp' })->discard_changes; - $c->stash->{status_message} = '<p><em>' . _('That problem has been marked as sent.') . '</em></p>'; - $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] ); - } - elsif ( $c->get_param('flaguser') ) { - $c->forward('users/flag'); - $c->stash->{problem}->discard_changes; - } - elsif ( $c->get_param('removeuserflag') ) { - $c->forward('users/flag_remove'); - $c->stash->{problem}->discard_changes; - } - elsif ( $c->get_param('banuser') ) { - $c->forward('users/ban'); - } - elsif ( $c->get_param('submit') ) { - $c->forward('/auth/check_csrf_token'); - - my $old_state = $problem->state; - - my %columns = ( - flagged => $c->get_param('flagged') ? 1 : 0, - non_public => $c->get_param('non_public') ? 1 : 0, - ); - foreach (qw/state anonymous title detail name external_id external_body external_team/) { - $columns{$_} = $c->get_param($_); - } - $problem->set_inflated_columns(\%columns); - - if ($c->get_param('closed_updates')) { - $problem->set_extra_metadata(closed_updates => 1); - } else { - $problem->unset_extra_metadata('closed_updates'); - } - - $c->forward( '/admin/report_edit_category', [ $problem, $problem->state ne $old_state ] ); - $c->forward('update_user', [ $problem ]); - - # Deal with photos - my $remove_photo_param = $self->_get_remove_photo_param($c); - if ($remove_photo_param) { - $self->remove_photo($c, $problem, $remove_photo_param); - } - - if ($problem->state eq 'hidden') { - $problem->get_photoset->delete_cached; - } - - if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) { - $problem->confirmed( \'current_timestamp' ); - } - - $problem->lastupdate( \'current_timestamp' ); - $problem->update; - - if ( $problem->state ne $old_state ) { - $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] ); - - my $name = $c->user->moderating_user_name; - my $extra = { is_superuser => 1 }; - if ($c->user->from_body) { - delete $extra->{is_superuser}; - $extra->{is_body_user} = $c->user->from_body->id; - } - my $timestamp = \'current_timestamp'; - $problem->add_to_comments( { - text => $c->stash->{update_text} || '', - created => $timestamp, - confirmed => $timestamp, - user_id => $c->user->id, - name => $name, - mark_fixed => 0, - anonymous => 0, - state => 'confirmed', - problem_state => $problem->state, - extra => $extra - } ); - } - $c->forward( 'log_edit', [ $id, 'problem', 'edit' ] ); - - $c->stash->{status_message} = - '<p><em>' . _('Updated!') . '</em></p>'; - - # do this here otherwise lastupdate and confirmed times - # do not display correctly - $problem->discard_changes; - } - - $c->detach('report_edit_display'); -} - -=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 - -sub report_edit_category : Private { - my ($self, $c, $problem, $no_comment) = @_; - - if ((my $category = $c->get_param('category')) ne $problem->category) { - my $category_old = $problem->category; - $problem->category($category); - my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}}; - my @new_body_ids = map { $_->body_id } @contacts; - # 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->resend; - } - # If the send methods of the old/new contacts differ we need to resend the report - my @new_send_methods = uniq map { - ( $_->body->can_be_devolved && $_->send_method ) ? - $_->send_method : $_->body->send_method - ? $_->body->send_method - : $c->cobrand->_fallback_body_sender()->{method}; - } @contacts; - my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); - if (grep !$old_send_methods{$_}, @new_send_methods) { - $problem->resend; - } - - $problem->bodies_str(join( ',', @new_body_ids )); - my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*'; - if ($no_comment) { - $c->stash->{update_text} = $update_text; - } else { - $problem->add_to_comments({ - text => $update_text, - 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 => 0, - }); - } - return 1; - } - return 0; -} - -=head2 report_edit_location - -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 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. - -=cut - -sub report_edit_location : Private { - my ($self, $c, $problem) = @_; - - return 1 unless $c->forward('/location/determine_location_from_coords'); - - my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude; - if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) { - # The two actions below change the stash, setting things up for e.g. a - # new report. But here we're only doing it in order to check the found - # bodies match; we don't want to overwrite the existing report data if - # 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}; - my @new_bodies = keys %{$c->stash->{bodies_to_list}}; - my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies; - $c->stash($safe_stash); - 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; -} - -sub categories_for_point : Private { - my ($self, $c) = @_; - - $c->stash->{report} = $c->stash->{problem}; - # We have a report, stash its location - $c->forward('/report/new/determine_location_from_report'); - # Look up the areas for this location - my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ]; - $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]); - # As with a new report, fetch the bodies/categories - $c->forward('/report/new/setup_categories_and_bodies'); - - # Remove the "Pick a category" option - shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}}; - - $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 ) = @_; - - my $user = $c->user; - - if ($user->is_superuser) { - $c->forward('fetch_all_bodies'); - $c->stash->{template} = 'admin/templates_index.html'; - } elsif ( $user->from_body ) { - $c->forward('load_template_body', [ $user->from_body->id ]); - $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) ); - } else { - $c->detach( '/page_error_404_not_found', [] ); - } -} - -sub templates_view : Path('templates') : Args(1) { - my ($self, $c, $body_id) = @_; - - $c->forward('load_template_body', [ $body_id ]); - - my @templates = $c->stash->{body}->response_templates->search( - undef, - { - order_by => 'title' - } - ); - - $c->stash->{response_templates} = \@templates; - - $c->stash->{template} = 'admin/templates.html'; -} - -sub template_edit : Path('templates') : Args(2) { - my ( $self, $c, $body_id, $template_id ) = @_; - - $c->forward('load_template_body', [ $body_id ]); - - my $template; - if ($template_id eq 'new') { - $template = $c->stash->{body}->response_templates->new({}); - } - else { - $template = $c->stash->{body}->response_templates->find( $template_id ) - or $c->detach( '/page_error_404_not_found', [] ); - } - - $c->forward('fetch_contacts'); - my @contacts = $template->contacts->all; - my @live_contacts = $c->stash->{live_contacts}->all; - my %active_contacts = map { $_->id => 1 } @contacts; - my @all_contacts = map { { - id => $_->id, - category => $_->category_display, - active => $active_contacts{$_->id}, - email => $_->email, - } } @live_contacts; - $c->stash->{contacts} = \@all_contacts; - - # bare block to use 'last' if form is invalid. - if ($c->req->method eq 'POST') { { - if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) { - $template->contact_response_templates->delete_all; - $template->delete; - } else { - my @live_contact_ids = map { $_->id } @live_contacts; - my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; - my %new_contacts = map { $_ => 1 } @new_contact_ids; - for my $contact (@all_contacts) { - $contact->{active} = $new_contacts{$contact->{id}}; - } - - $template->title( $c->get_param('title') ); - $template->text( $c->get_param('text') ); - $template->state( $c->get_param('state') ); - $template->external_status_code( $c->get_param('external_status_code') ); - - if ( $template->state && $template->external_status_code ) { - $c->stash->{errors} ||= {}; - $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously."); - $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously."); - } - - $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 ); - if ($template->auto_response) { - my @check_contact_ids = @new_contact_ids; - # If the new template has not specific categories (i.e. it - # applies to all categories) then we need to check each of those - # category ids for existing auto-response templates. - if (!scalar @check_contact_ids) { - @check_contact_ids = @live_contact_ids; - } - my $query = { - 'auto_response' => 1, - 'contact.id' => [ @check_contact_ids, undef ], - -or => { - $template->state ? ('me.state' => $template->state) : (), - $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (), - }, - }; - if ($template->in_storage) { - $query->{'me.id'} = { '!=', $template->id }; - } - if ($c->stash->{body}->response_templates->search($query, { - join => { 'contact_response_templates' => 'contact' }, - })->count) { - $c->stash->{errors} ||= {}; - $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state."); - } - } - - last if $c->stash->{errors}; - - $template->update_or_insert; - $template->contact_response_templates->search({ - contact_id => { '!=' => \@new_contact_ids }, - })->delete; - foreach my $contact_id (@new_contact_ids) { - $template->contact_response_templates->find_or_create({ - contact_id => $contact_id, - }); - } - } - - $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) ); - } } - - $c->stash->{response_template} = $template; - - $c->stash->{template} = 'admin/template_edit.html'; -} - -sub load_template_body : Private { - my ($self, $c, $body_id) = @_; - - my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich'; - my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id); - - unless ( $zurich_user || $has_permission ) { - $c->detach( '/page_error_404_not_found', [] ); - } - - # Regular users can only view their own body's templates - if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) { - $c->res->redirect( $c->uri_for( 'templates', $c->user->from_body->id ) ); - } - - $c->stash->{body} = $c->model('DB::Body')->find($body_id) - or $c->detach( '/page_error_404_not_found', [] ); -} - sub update_edit : Path('update_edit') : Args(1) { my ( $self, $c, $id ) = @_; @@ -872,8 +196,8 @@ sub update_edit : Path('update_edit') : Args(1) { $c->stash->{update} = $update; - if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { - $self->rotate_photo($c, $update, @$rotate_photo_param); + if (my $rotate_photo_param = $c->forward('_get_rotate_photo_param')) { + $c->forward('rotate_photo', [ $update, @$rotate_photo_param ]); return 1; } @@ -907,18 +231,17 @@ sub update_edit : Path('update_edit') : Args(1) { $edited = 1; } - my $remove_photo_param = $self->_get_remove_photo_param($c); + my $remove_photo_param = $c->forward('_get_remove_photo_param'); if ($remove_photo_param) { - $self->remove_photo($c, $update, $remove_photo_param); + $c->forward('remove_photo', [$update, $remove_photo_param]); } - $c->stash->{status_message} = '<p><em>' . _('Updated!') . '</em></p>'; + $c->stash->{status_message} = _('Updated!'); # Must call update->hide while it's not hidden (so is_latest works) if ($new_state eq 'hidden') { my $outcome = $update->hide; - $c->stash->{status_message} .= - '<p><em>' . _('Problem marked as open.') . '</em></p>' + $c->stash->{status_message} .= _('Problem marked as open.') if $outcome->{reopened}; } @@ -1014,8 +337,8 @@ sub set_allowed_pages : Private { sub get_user : Private { my ( $self, $c ) = @_; - my $user = $c->req->remote_user(); - $user ||= ($c->user && $c->user->name); + my $user = ($c->user && $c->user->name); + $user ||= $c->req->remote_user(); $user ||= ''; return $user; @@ -1075,7 +398,7 @@ Rotate a photo 90 degrees left or right =cut # returns index of photo to rotate, if any -sub _get_rotate_photo_param { +sub _get_rotate_photo_param : Private { my ($self, $c) = @_; my $key = first { /^rotate_photo/ } keys %{ $c->req->params } or return; my ($index) = $key =~ /(\d+)$/; @@ -1105,7 +428,7 @@ Remove a photo from a report =cut # Returns index of photo(s) to remove, if any -sub _get_remove_photo_param { +sub _get_remove_photo_param : Private { my ($self, $c) = @_; return 'ALL' if $c->get_param('remove_photo'); @@ -1117,8 +440,8 @@ sub _get_remove_photo_param { sub remove_photo : Private { my ($self, $c, $object, $keys) = @_; if ($keys eq 'ALL') { - $object->photo(undef); $object->get_photoset->delete_cached; + $object->photo(undef); } else { my $fileids = $object->get_photoset->remove_images($keys); $object->photo($fileids); @@ -1194,31 +517,46 @@ sub update_extra_fields : Private { my $meta = {}; $meta->{code} = $c->get_param("metadata[$i].code"); next unless $meta->{code}; + $meta->{order} = int $c->get_param("metadata[$i].order"); - $meta->{datatype} = $c->get_param("metadata[$i].datatype"); - my $required = $c->get_param("metadata[$i].required") && $c->get_param("metadata[$i].required") eq 'on'; - $meta->{required} = $required ? 'true' : 'false'; - my $notice = $c->get_param("metadata[$i].notice") && $c->get_param("metadata[$i].notice") eq 'on'; - $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} = []; - my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key}; - my @vindices = grep { /$re/ } keys %{ $c->req->params }; - @vindices = sort map { /values\[(\d+)\]/ } @vindices; - foreach my $j (@vindices) { - my $name = $c->get_param("metadata[$i].values[$j].name"); - my $key = $c->get_param("metadata[$i].values[$j].key"); - push(@{$meta->{values}}, { - name => $name, - key => $key, - }) if $name; + $meta->{protected} = $c->get_param("metadata[$i].protected") ? 'true' : 'false'; + + my $behaviour = $c->get_param("metadata[$i].behaviour") || 'question'; + if ($behaviour eq 'question') { + $meta->{required} = $c->get_param("metadata[$i].required") ? 'true' : 'false'; + $meta->{variable} = 'true'; + my $desc = $c->get_param("metadata[$i].description"); + $meta->{description} = FixMyStreet::Template::sanitize($desc); + $meta->{datatype} = $c->get_param("metadata[$i].datatype"); + + if ( $meta->{datatype} eq "singlevaluelist" ) { + $meta->{values} = []; + my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key}; + my @vindices = grep { /$re/ } keys %{ $c->req->params }; + @vindices = sort map { /values\[(\d+)\]/ } @vindices; + foreach my $j (@vindices) { + my $name = $c->get_param("metadata[$i].values[$j].name"); + my $key = $c->get_param("metadata[$i].values[$j].key"); + my $disable = $c->get_param("metadata[$i].values[$j].disable"); + my $disable_message = $c->get_param("metadata[$i].values[$j].disable_message"); + push(@{$meta->{values}}, { + name => $name, + key => $key, + $disable ? (disable => 1, disable_message => $disable_message) : (), + }) if $name; + } } + } elsif ($behaviour eq 'notice') { + $meta->{variable} = 'false'; + my $desc = $c->get_param("metadata[$i].description"); + $meta->{description} = FixMyStreet::Template::sanitize($desc); + $meta->{disable_form} = $c->get_param("metadata[$i].disable_form") ? 'true' : 'false'; + } elsif ($behaviour eq 'hidden') { + $meta->{automated} = 'hidden_field'; + } elsif ($behaviour eq 'server') { + $meta->{automated} = 'server_set'; } + push @extra_fields, $meta; } @extra_fields = sort { $a->{order} <=> $b->{order} } @extra_fields; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm index 0e47d2238..6ae068cd9 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm @@ -51,27 +51,7 @@ sub index : Path : Args(0) { 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('update_body', [ undef, _('New body added') ]); } $c->forward( '/admin/fetch_all_bodies' ); @@ -147,6 +127,8 @@ sub edit : Chained('body') : PathPart('') : Args(0) { # to display email addresses as text $c->stash->{template} = 'admin/bodies/body.html'; $c->forward('/admin/fetch_contacts'); + $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; + $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]); return 1; } @@ -158,7 +140,8 @@ sub category : Chained('body') : PathPart('') { $c->forward( '/auth/get_csrf_token' ); my $contact = $c->stash->{body}->contacts->search( { category => $category } )->first; - $c->stash->{contact} = $contact; + $c->detach( '/page_error_404_not_found', [] ) unless $contact; + $c->stash->{contact} = $c->stash->{current_contact} = $contact; $c->stash->{translation_col} = 'category'; $c->stash->{object} = $c->stash->{contact}; @@ -220,140 +203,199 @@ sub check_for_super_user : Private { sub update_contacts : Private { my ( $self, $c ) = @_; - my $posted = $c->get_param('posted'); - my $editor = $c->forward('/admin/get_user'); - + my $posted = $c->get_param('posted') || ''; if ( $posted eq 'new' ) { - $c->forward('/auth/check_csrf_token'); + $c->forward('update_contact'); + } elsif ( $posted eq 'update' ) { + $c->forward('confirm_contacts'); + } elsif ( $posted eq 'body' ) { + $c->forward('update_body', [ $c->stash->{body}, _('Values updated') ]); + } +} - my %errors; +sub update_contact : Private { + my ( $self, $c ) = @_; - 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 $editor = $c->forward('/admin/get_user'); + $c->forward('/auth/check_csrf_token'); - my $contact = $c->model('DB::Contact')->find_or_new( - { - body_id => $c->stash->{body_id}, - category => $category, - } - ); + my %errors; - 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 $current_category = $c->get_param('current_category') || ''; + my $current_contact = $c->model('DB::Contact')->find({ + body_id => $c->stash->{body_id}, + category => $current_category, + }); + $c->stash->{current_contact} = $current_contact; - $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')) ); + 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') || FixMyStreet->config('STAGING_SITE'); + + my $contact = $c->model('DB::Contact')->find_or_new( + { + body_id => $c->stash->{body_id}, + category => $category, } - if ( my $group = $c->get_param('group') ) { - $contact->set_extra_metadata( group => $group ); - } else { + ); + if ($current_contact && $contact->id && $contact->id != $current_contact->id) { + $errors{category} = _('You cannot rename a category to an existing category'); + } elsif ($current_contact && !$contact->id) { + # Changed name + $contact = $current_contact; + $c->model('DB::Problem')->to_body($c->stash->{body_id})->search({ category => $current_category })->update({ category => $category }); + $contact->category($category); + } + + my $email = $c->get_param('email'); + $email =~ s/\s+//g; + my $send_method = $c->get_param('send_method') || $contact->body->send_method || ""; + my $email_unchanged = $contact->email && $email && $contact->email eq $email; + unless ( $send_method eq 'Open311' || $email_unchanged ) { + $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('open311_protect') ) { + $contact->set_extra_metadata( open311_protect => 1 ); + } else { + $contact->unset_extra_metadata( 'open311_protect' ); + } + if ( my @group = $c->get_param_list('group') ) { + @group = grep { $_ } @group; + if (scalar @group == 0) { $contact->unset_extra_metadata( 'group' ); + } else { + $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, \%errors ]); + + # Special form disabling form + if ($c->get_param('disable')) { + my $msg = $c->get_param('disable_message'); + $msg = FixMyStreet::Template::sanitize($msg); + $errors{category} = _('Please enter a message') unless $msg; + my $meta = { + code => '_fms_disable_', + variable => 'false', + protected => 'true', + disable_form => 'true', + description => $msg, + }; + $contact->update_extra_field($meta); + } else { + $contact->remove_extra_field('_fms_disable_'); + } + + 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'); + $c->forward('/admin/log_edit', [ $contact->id, 'category', 'edit' ]); + # NB: History is automatically stored by a trigger in the database + $contact->update; + } else { + $c->stash->{updated} = _('New category contact added'); + $contact->insert; + $c->forward('/admin/log_edit', [ $contact->id, 'category', 'add' ]); + } + unless ( %errors ) { + $c->stash->{translation_col} = 'category'; + $c->stash->{object} = $contact; + $c->forward('update_translations'); + } - $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'); +sub confirm_contacts : Private { + my ( $self, $c ) = @_; - # NB: History is automatically stored by a trigger in the database - $contact->update; - } else { - $c->stash->{updated} = _('New category contact added'); - $contact->insert; + $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 }, } + ); - unless ( %errors ) { - $c->stash->{translation_col} = 'category'; - $c->stash->{object} = $contact; - $c->forward('update_translations'); + my $editor = $c->forward('/admin/get_user'); + $contacts->update( + { + state => 'confirmed', + whenedited => \'current_timestamp', + note => 'Confirmed', + editor => $editor, } + ); - } elsif ( $posted eq 'update' ) { - $c->forward('/auth/check_csrf_token'); + $c->forward('/admin/log_edit', [ $c->stash->{body_id}, 'body', 'edit' ]); + $c->stash->{updated} = _('Values updated'); +} - my @categories = $c->get_param_list('confirmed'); +sub update_body : Private { + my ($self, $c, $body, $msg) = @_; - 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->forward('check_for_super_user'); + $c->forward('/auth/check_csrf_token'); - $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; + my $values = $c->forward('body_params'); + return if %{$c->stash->{body_errors}}; - $c->stash->{translation_col} = 'name'; - $c->stash->{object} = $c->stash->{body}; - $c->forward('update_translations'); + if ($body) { + $body->update( $values->{params} ); + $c->forward('/admin/log_edit', [ $body->id, 'body', 'edit' ]); + } else { + $body = $c->model('DB::Body')->create( $values->{params} ); + $c->forward('/admin/log_edit', [ $body->id, 'body', 'add' ]); + } - $c->stash->{updated} = _('Values updated'); - } + if ($values->{extras}) { + $body->set_extra_metadata( $_ => $values->{extras}->{$_} ) + for keys %{$values->{extras}}; + $body->update; } + my @current = $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 => $body, area_id => $_ } ); + delete $current{$_}; + } + # Remove any others + $body->body_areas->search( { area_id => [ keys %current ] } )->delete; + + $c->stash->{translation_col} = 'name'; + $c->stash->{object} = $body; + $c->forward('update_translations'); + + $c->stash->{updated} = $msg; } sub body_params : Private { @@ -375,9 +417,13 @@ sub body_params : Private { ); my %params = map { $_ => $c->get_param($_) || $defaults{$_} } keys %defaults; $c->forward('check_body_params', [ \%params ]); + my @extras = qw/fetch_all_problems/; + my $cobrand_extras = $c->cobrand->call_hook('body_extra_fields'); + push @extras, @$cobrand_extras if $cobrand_extras; + %defaults = map { $_ => '' } @extras; - my %extras = map { $_ => $c->get_param($_) || $defaults{$_} } @extras; + my %extras = map { $_ => $c->get_param("extra[$_]") || $defaults{$_} } @extras; return { params => \%params, extras => \%extras }; } @@ -392,12 +438,13 @@ sub check_body_params : Private { } sub contact_cobrand_extra_fields : Private { - my ( $self, $c, $contact ) = @_; + my ( $self, $c, $contact, $errors ) = @_; my $extra_fields = $c->cobrand->call_hook('contact_extra_fields'); foreach ( @$extra_fields ) { $contact->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); } + $c->cobrand->call_hook(contact_extra_fields_validation => $contact, $errors); } sub fetch_translations : Private { diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm index ed9b40fd0..6c1a25e5a 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm @@ -76,7 +76,7 @@ sub edit : Path : Args(2) { my @new_contact_ids = $c->get_param_list('categories'); @new_contact_ids = @{ mySociety::ArrayUtils::intersection(\@live_contact_ids, \@new_contact_ids) }; $defect_type->contact_defect_types->search({ - contact_id => { '!=' => \@new_contact_ids }, + contact_id => { -not_in => \@new_contact_ids }, })->delete; foreach my $contact_id (@new_contact_ids) { $defect_type->contact_defect_types->find_or_create({ diff --git a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm deleted file mode 100644 index 0026acb9c..000000000 --- a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm +++ /dev/null @@ -1,81 +0,0 @@ -package FixMyStreet::App::Controller::Admin::ExorDefects; -use Moose; -use namespace::autoclean; - -use DateTime; -use Try::Tiny; -use FixMyStreet::Integrations::ExorRDI; -use FixMyStreet::DateRange; - -BEGIN { extends 'Catalyst::Controller'; } - - -sub index : Path : Args(0) { - my ( $self, $c ) = @_; - - foreach (qw(error_message start_date end_date user_id)) { - if ( defined $c->flash->{$_} ) { - $c->stash->{$_} = $c->flash->{$_}; - } - } - - my @inspectors = $c->cobrand->users->search({ - 'user_body_permissions.permission_type' => 'report_inspect' - }, { - join => 'user_body_permissions', - distinct => 1, - } - )->all; - $c->stash->{inspectors} = \@inspectors; - - # Default start/end date is today - my $now = DateTime->now( time_zone => - FixMyStreet->time_zone || FixMyStreet->local_time_zone ); - $c->stash->{start_date} ||= $now; - $c->stash->{end_date} ||= $now; - -} - -sub download : Path('download') : Args(0) { - my ( $self, $c ) = @_; - - if ( !$c->cobrand->can('exor_rdi_link_id') ) { - # This only works on the Oxfordshire cobrand currently. - $c->detach( '/page_error_404_not_found', [] ); - } - - 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 => $range->start, - inspection_date => $range->start, - end_date => $range->end, - user => $c->get_param('user_id'), - mark_as_processed => 0, - }; - my $rdi = FixMyStreet::Integrations::ExorRDI->new($params); - - try { - my $out = $rdi->construct; - $c->res->content_type('text/csv; charset=utf-8'); - $c->res->header('content-disposition' => "attachment; filename=" . $rdi->filename); - $c->res->body( $out ); - } catch { - die $_ unless $_ =~ /FixMyStreet::Integrations::ExorRDI::Error/; - if ($params->{user}) { - $c->flash->{error_message} = _("No inspections by that inspector in the selected date range."); - } else { - $c->flash->{error_message} = _("No inspections in the selected date range."); - } - $c->flash->{start_date} = $params->{start_date}; - $c->flash->{end_date} = $params->{end_date}; - $c->flash->{user_id} = $params->{user}; - $c->res->redirect( $c->uri_for( '' ) ); - }; -} - -1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm new file mode 100644 index 000000000..9e3bdc33e --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm @@ -0,0 +1,99 @@ +package FixMyStreet::App::Controller::Admin::ManifestTheme; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use FixMyStreet::App::Form::ManifestTheme; + +sub auto :Private { + my ($self, $c) = @_; + + if ( $c->cobrand->moniker eq 'fixmystreet' ) { + $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({}), show_all => 1); + } else { + $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({ cobrand => $c->cobrand->moniker })); + } +} + +sub index :Path :Args(0) { + my ( $self, $c ) = @_; + + unless ( $c->stash->{show_all} ) { + if ( $c->stash->{rs}->count ) { + $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ])); + } else { + $c->res->redirect($c->uri_for($self->action_for('create'))); + } + $c->detach; + } +} + +sub item :PathPart('admin/manifesttheme') :Chained :CaptureArgs(1) { + my ($self, $c, $cobrand) = @_; + + my $obj = $c->stash->{rs}->find({ cobrand => $cobrand }) + or $c->detach('/page_error_404_not_found', []); + $c->stash(obj => $obj); +} + +sub edit :PathPart('') :Chained('item') :Args(0) { + my ($self, $c) = @_; + + my $form = $self->form($c, $c->stash->{obj}); + + # We need to do this after form processing, in case a form POST has deleted + # an icon. + $c->stash->{editing_manifest_theme} = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]); + + return $form; +} + + +sub create :Local :Args(0) { + my ($self, $c) = @_; + + unless ( $c->stash->{show_all} || $c->stash->{rs}->count == 0) { + $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ])); + $c->detach; + } + + my $theme = $c->stash->{rs}->new_result({}); + return $self->form($c, $theme); +} + +sub form { + my ($self, $c, $theme) = @_; + + if ($c->get_param('delete_theme')) { + $c->forward('_delete_all_manifest_icons'); + $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]); + $theme->delete; + $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', 'delete' ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); + $c->detach; + } + + my $action = $theme->in_storage ? 'edit' : 'add'; + my $form = FixMyStreet::App::Form::ManifestTheme->new( cobrand => $c->cobrand->moniker ); + $c->stash(template => 'admin/manifesttheme/form.html', form => $form); + my $params = $c->req->params; + $params->{icon} = $c->req->upload('icon') if $params->{icon}; + $form->process(item => $theme, params => $params); + return unless $form->validated; + + $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', $action ]); + $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); +} + +sub _delete_all_manifest_icons :Private { + my ($self, $c) = @_; + + my $theme = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]); + foreach my $icon ( @{ $theme->{icons} } ) { + unlink FixMyStreet->path_to('web', $icon->{src}); + } +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Reports.pm b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm new file mode 100644 index 000000000..7300fe676 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm @@ -0,0 +1,523 @@ +package FixMyStreet::App::Controller::Admin::Reports; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use List::MoreUtils 'uniq'; +use FixMyStreet::SMS; +use Utils; + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Reports - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages + +=head1 METHODS + +=cut + +sub index : Path { + my ( $self, $c ) = @_; + + $c->stash->{edit_body_contacts} = 1 + if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}}; + + my $query = {}; + if ( $c->cobrand->moniker eq 'zurich' ) { + my $type = $c->stash->{admin_type}; + my $body = $c->stash->{body}; + if ( $type eq 'dm' ) { + my @children = map { $_->id } $body->bodies->all; + my @all = (@children, $body->id); + $query = { bodies_str => \@all }; + } elsif ( $type eq 'sdm' ) { + $query = { bodies_str => $body->id }; + } + } + + my $order = $c->get_param('o') || 'id'; + my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1; + $c->stash->{order} = $order; + $c->stash->{dir} = $dir; + $order = $dir ? { -desc => "me.$order" } : "me.$order"; + + my $p_page = $c->get_param('p') || 1; + my $u_page = $c->get_param('u') || 1; + + 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; + $search_n = int($search) if $search =~ /^\d+$/; + + my $like_search = "%$search%"; + + my $parsed = FixMyStreet::SMS->parse_username($search); + my $valid_phone = $parsed->{phone}; + my $valid_email = $parsed->{email}; + + if ($valid_email) { + $query->{'-or'} = [ + 'user.email' => { ilike => $like_search }, + ]; + } elsif ($valid_phone) { + $query->{'-or'} = [ + 'user.phone' => { ilike => $like_search }, + ]; + } elsif ($search =~ /^id:(\d+)$/) { + $query->{'-or'} = [ + 'me.id' => int($1), + ]; + } elsif ($search =~ /^area:(\d+)$/) { + $query->{'-or'} = [ + 'me.areas' => { like => "%,$1,%" } + ]; + } elsif ($search =~ /^ref:(\d+)$/) { + $query->{'-or'} = [ + 'me.external_id' => { like => "%$1%" } + ]; + } else { + $query->{'-or'} = [ + 'me.id' => $search_n, + 'user.email' => { ilike => $like_search }, + 'user.phone' => { ilike => $like_search }, + 'me.external_id' => { ilike => $like_search }, + 'me.name' => { ilike => $like_search }, + 'me.title' => { ilike => $like_search }, + detail => { ilike => $like_search }, + bodies_str => { like => $like_search }, + cobrand_data => { like => $like_search }, + ]; + } + + my $problems = $c->cobrand->problems->search( + $query, + { + join => 'user', + '+columns' => 'user.email', + rows => 50, + order_by => $order, + } + )->page( $p_page ); + + $c->stash->{problems} = [ $problems->all ]; + $c->stash->{problems_pager} = $problems->pager; + + if ($valid_email) { + $query = [ + 'user.email' => { ilike => $like_search }, + ]; + } elsif ($valid_phone) { + $query = [ + 'user.phone' => { ilike => $like_search }, + ]; + } elsif ($search =~ /^id:(\d+)$/) { + $query = [ + 'me.id' => int($1), + 'me.problem_id' => int($1), + ]; + } elsif ($search =~ /^area:(\d+)$/) { + $query = []; + } else { + $query = [ + 'me.id' => $search_n, + 'problem.id' => $search_n, + 'user.email' => { ilike => $like_search }, + 'user.phone' => { ilike => $like_search }, + 'me.name' => { ilike => $like_search }, + text => { ilike => $like_search }, + 'me.cobrand_data' => { ilike => $like_search }, + ]; + } + + if (@$query) { + my $updates = $c->cobrand->updates->search( + { + -or => $query, + }, + { + '+columns' => ['user.email'], + join => 'user', + prefetch => [qw/problem/], + rows => 50, + order_by => { -desc => 'me.id' } + } + )->page( $u_page ); + $c->stash->{updates} = [ $updates->all ]; + $c->stash->{updates_pager} = $updates->pager; + } + + } else { + + my $problems = $c->cobrand->problems->search( + $query, + { order_by => $order, rows => 50 } + )->page( $p_page ); + $c->stash->{problems} = [ $problems->all ]; + $c->stash->{problems_pager} = $problems->pager; + } +} + +sub edit_display : Private { + my ( $self, $c ) = @_; + + my $problem = $c->stash->{problem}; + + $c->stash->{page} = 'admin'; + FixMyStreet::Map::display_map( + $c, + latitude => $problem->latitude, + longitude => $problem->longitude, + pins => $problem->used_map + ? [ { + latitude => $problem->latitude, + longitude => $problem->longitude, + colour => $c->cobrand->pin_colour($problem, 'admin'), + type => 'big', + draggable => 1, + } ] + : [], + print_report => 1, + ); +} + +sub edit : Path('/admin/report_edit') : Args(1) { + my ( $self, $c, $id ) = @_; + + my $problem = $c->cobrand->problems->search( { id => $id } )->first; + + $c->detach( '/page_error_404_not_found', [] ) + unless $problem; + + unless ( + $c->cobrand->moniker eq 'zurich' + || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids) + ) { + $c->detach( '/page_error_403_access_denied', [] ); + } + + $c->stash->{problem} = $problem; + if ( $problem->extra ) { + my @fields; + if ( my $fields = $problem->get_extra_fields ) { + for my $field ( @{$fields} ) { + my $name = $field->{description} ? + "$field->{description} ($field->{name})" : + "$field->{name}"; + push @fields, { name => $name, val => $field->{value} }; + } + } + my $extra = $problem->get_extra_metadata; + if ( $extra->{duplicates} ) { + push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) }; + delete $extra->{duplicates}; + } + for my $key ( keys %$extra ) { + push @fields, { name => $key, val => $extra->{$key} }; + } + + $c->stash->{extra_fields} = \@fields; + } + + $c->forward('/auth/get_csrf_token'); + + $c->forward('categories_for_point'); + + $c->forward('alerts_for_report'); + + $c->forward('/admin/check_username_for_abuse', [ $problem->user ] ); + + $c->stash->{updates} = + [ $c->model('DB::Comment') + ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } ) + ->all ]; + + if (my $rotate_photo_param = $c->forward('/admin/_get_rotate_photo_param')) { + $c->forward('/admin/rotate_photo', [$problem, @$rotate_photo_param]); + $c->detach('edit_display'); + } + + if ( $c->cobrand->moniker eq 'zurich' ) { + my $done = $c->cobrand->admin_report_edit(); + $c->detach('edit_display') if $done; + } + + if ( $c->get_param('resend') && !$c->cobrand->call_hook('disable_resend_button') ) { + $c->forward('/auth/check_csrf_token'); + + $problem->resend; + $problem->update(); + $c->stash->{status_message} = _('That problem will now be resent.'); + + $c->forward( '/admin/log_edit', [ $id, 'problem', 'resend' ] ); + } + elsif ( $c->get_param('mark_sent') ) { + $c->forward('/auth/check_csrf_token'); + $problem->update({ whensent => \'current_timestamp' })->discard_changes; + $c->stash->{status_message} = _('That problem has been marked as sent.'); + $c->forward( '/admin/log_edit', [ $id, 'problem', 'marked sent' ] ); + } + elsif ( $c->get_param('flaguser') ) { + $c->forward('/admin/users/flag'); + $c->stash->{problem}->discard_changes; + } + elsif ( $c->get_param('removeuserflag') ) { + $c->forward('/admin/users/flag_remove'); + $c->stash->{problem}->discard_changes; + } + elsif ( $c->get_param('banuser') ) { + $c->forward('/admin/users/ban'); + } + elsif ( $c->get_param('submit') ) { + $c->forward('/auth/check_csrf_token'); + + my $old_state = $problem->state; + + my %columns = ( + flagged => $c->get_param('flagged') ? 1 : 0, + non_public => $c->get_param('non_public') ? 1 : 0, + ); + foreach (qw/state anonymous title detail name external_id external_body external_team/) { + $columns{$_} = $c->get_param($_); + } + + # Look this up here for moderation line to use + my $remove_photo_param = $c->forward('/admin/_get_remove_photo_param'); + + if ($columns{title} ne $problem->title || $columns{detail} ne $problem->detail || + $columns{anonymous} ne $problem->anonymous || $remove_photo_param) { + $problem->create_related( moderation_original_data => { + title => $problem->title, + detail => $problem->detail, + photo => $problem->photo, + anonymous => $problem->anonymous, + category => $problem->category, + $problem->extra ? (extra => $problem->extra) : (), + }); + } + + $problem->set_inflated_columns(\%columns); + + if ($c->get_param('closed_updates')) { + $problem->set_extra_metadata(closed_updates => 1); + } else { + $problem->unset_extra_metadata('closed_updates'); + } + + $c->forward( '/admin/reports/edit_category', [ $problem, $problem->state ne $old_state ] ); + $c->forward('/admin/update_user', [ $problem ]); + + # Deal with photos + if ($remove_photo_param) { + $c->forward('/admin/remove_photo', [ $problem, $remove_photo_param ]); + } + + if ($problem->state eq 'hidden' || $problem->non_public) { + $problem->get_photoset->delete_cached(plus_updates => 1); + } + + if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) { + $problem->confirmed( \'current_timestamp' ); + } + + $problem->lastupdate( \'current_timestamp' ); + $problem->update; + + if ( $problem->state ne $old_state ) { + $c->forward( '/admin/log_edit', [ $id, 'problem', 'state_change' ] ); + + my $name = $c->user->moderating_user_name; + my $extra = { is_superuser => 1 }; + if ($c->user->from_body) { + delete $extra->{is_superuser}; + $extra->{is_body_user} = $c->user->from_body->id; + } + my $timestamp = \'current_timestamp'; + $problem->add_to_comments( { + text => $c->stash->{update_text} || '', + created => $timestamp, + confirmed => $timestamp, + user_id => $c->user->id, + name => $name, + mark_fixed => 0, + anonymous => 0, + state => 'confirmed', + problem_state => $problem->state, + extra => $extra + } ); + } + $c->forward( '/admin/log_edit', [ $id, 'problem', 'edit' ] ); + + $c->stash->{status_message} = _('Updated!'); + + # do this here otherwise lastupdate and confirmed times + # do not display correctly + $problem->discard_changes; + } + + $c->detach('edit_display'); +} + +=head2 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 + +sub edit_category : Private { + my ($self, $c, $problem, $no_comment) = @_; + + if ((my $category = $c->get_param('category')) ne $problem->category) { + my $force_resend = $c->cobrand->call_hook('category_change_force_resend', $problem->category, $category); + my $disable_resend = $c->cobrand->call_hook('disable_resend'); + my $category_old = $problem->category; + $problem->category($category); + my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}}; + my @new_body_ids = map { $_->body_id } @contacts; + # 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 (!$disable_resend && grep !$old_map{$_}, @new_body_ids) { + $problem->resend; + } + # If the send methods of the old/new contacts differ we need to resend the report + my @new_send_methods = uniq map { + ( $_->body->can_be_devolved && $_->send_method ) ? + $_->send_method : $_->body->send_method + ? $_->body->send_method + : $c->cobrand->_fallback_body_sender()->{method}; + } @contacts; + my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); + if (!$disable_resend && grep !$old_send_methods{$_}, @new_send_methods) { + $problem->resend; + } + if ($force_resend) { + $problem->resend; + } + + $problem->bodies_str(join( ',', @new_body_ids )); + my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*'; + if ($no_comment) { + $c->stash->{update_text} = $update_text; + } else { + $problem->add_to_comments({ + text => $update_text, + 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 => 0, + }); + } + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'category_change' ] ); + return 1; + } + return 0; +} + +=head2 edit_location + +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 2 if the new position (if any) is acceptable and changed, +1 if acceptable and unchanged, undef otherwise. + +NB: This must be called before edit_category, as that might modify +$problem->bodies_str. + +=cut + +sub edit_location : Private { + my ($self, $c, $problem) = @_; + + return 1 unless $c->forward('/location/determine_location_from_coords'); + + my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude; + if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) { + # The two actions below change the stash, setting things up for e.g. a + # new report. But here we're only doing it in order to check the found + # bodies match; we don't want to overwrite the existing report data if + # 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}; + my @new_bodies = keys %{$c->stash->{bodies_to_list}}; + my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies; + $c->stash($safe_stash); + 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; +} + +sub categories_for_point : Private { + my ($self, $c) = @_; + + $c->stash->{report} = $c->stash->{problem}; + # We have a report, stash its location + $c->forward('/report/new/determine_location_from_report'); + # Look up the areas for this location + my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ]; + $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]); + # As with a new report, fetch the bodies/categories + $c->stash->{categories_for_point} = 1; + $c->forward('/report/new/setup_categories_and_bodies'); + + # Remove the "Pick a category" option + shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}}; + + $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} }; + + $c->forward('/admin/triage/setup_categories'); + +} + +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 trim { + my $self = shift; + my $e = shift; + $e =~ s/^\s+//; + $e =~ s/\s+$//; + return $e; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm index 2613f6ae0..5e2908290 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm @@ -4,98 +4,94 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } +use FixMyStreet::App::Form::ResponsePriority; -sub index : Path : Args(0) { - my ( $self, $c ) = @_; +sub auto :Private { + my ($self, $c) = @_; my $user = $c->user; - if ($user->is_superuser) { - $c->forward('/admin/fetch_all_bodies'); - } elsif ( $user->from_body ) { - $c->forward('load_user_body', [ $user->from_body->id ]); - $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) ); - } else { - $c->detach( '/page_error_404_not_found' ); + $c->stash(rs => $c->model('DB::ResponsePriority')->search_rs(undef, { + prefetch => 'body', + order_by => ['body.name', 'me.name'] + })); + } elsif ($user->from_body) { + $c->stash(rs => $user->from_body->response_priorities->search_rs(undef, { + order_by => 'name' + })); } } -sub list : Path : Args(1) { - my ($self, $c, $body_id) = @_; - - $c->forward('load_user_body', [ $body_id ]); +sub index : Path : Args(0) { + my ( $self, $c ) = @_; - my @priorities = $c->stash->{body}->response_priorities->search( - undef, - { - order_by => 'name' - } + if (my $body_id = $c->get_param('body_id')) { + $c->res->redirect($c->uri_for($self->action_for('create'), [ $body_id ])); + $c->detach; + } + if ($c->user->is_superuser) { + $c->forward('/admin/fetch_all_bodies'); + } + $c->stash( + response_priorities => [ $c->stash->{rs}->all ], ); - - $c->stash->{response_priorities} = \@priorities; } -sub edit : Path : Args(2) { - my ( $self, $c, $body_id, $priority_id ) = @_; - - $c->forward('load_user_body', [ $body_id ]); +sub body :PathPart('admin/responsepriorities') :Chained :CaptureArgs(1) { + my ($self, $c, $body_id) = @_; - my $priority; - if ($priority_id eq 'new') { - $priority = $c->stash->{body}->response_priorities->new({}); - } - else { - $priority = $c->stash->{body}->response_priorities->find( $priority_id ) - or $c->detach( '/page_error_404_not_found' ); + my $user = $c->user; + if ($user->is_superuser) { + $c->stash->{body} = $c->model('DB::Body')->find($body_id); + } elsif ($user->from_body && $user->from_body->id == $body_id) { + $c->stash->{body} = $user->from_body; } - $c->forward('/admin/fetch_contacts'); - my @contacts = $priority->contacts->all; - my @live_contacts = $c->stash->{live_contacts}->all; - my %active_contacts = map { $_->id => 1 } @contacts; - my @all_contacts = map { { - id => $_->id, - category => $_->category, - active => $active_contacts{$_->id}, - } } @live_contacts; - $c->stash->{contacts} = \@all_contacts; - - if ($c->req->method eq 'POST') { - $priority->deleted( $c->get_param('deleted') ? 1 : 0 ); - $priority->name( $c->get_param('name') ); - $priority->description( $c->get_param('description') ); - $priority->external_id( $c->get_param('external_id') ); - $priority->is_default( $c->get_param('is_default') ? 1 : 0 ); - $priority->update_or_insert; - - my @live_contact_ids = map { $_->id } @live_contacts; - my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; - $priority->contact_response_priorities->search({ - contact_id => { '!=' => \@new_contact_ids }, - })->delete; - foreach my $contact_id (@new_contact_ids) { - $priority->contact_response_priorities->find_or_create({ - contact_id => $contact_id, - }); - } - - $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) ); - } + $c->detach( '/page_error_404_not_found' ) unless $c->stash->{body}; +} - $c->stash->{response_priority} = $priority; +sub create :Chained('body') :Args(0) { + my ($self, $c) = @_; + + my $priority = $c->stash->{rs}->new_result({ body => $c->stash->{body} }); + return $self->form($c, $priority); } -sub load_user_body : Private { - my ($self, $c, $body_id) = @_; +sub item :PathPart('') :Chained('body') :CaptureArgs(1) { + my ($self, $c, $id) = @_; - my $has_permission = $c->user->has_body_permission_to('responsepriority_edit', $body_id); + my $obj = $c->stash->{rs}->find($id) + or $c->detach('/page_error_404_not_found', []); + $c->stash(obj => $obj); +} - unless ( $has_permission ) { - $c->detach( '/page_error_404_not_found' ); - } +sub edit :PathPart('') :Chained('item') :Args(0) { + my ($self, $c) = @_; + return $self->form($c, $c->stash->{obj}); +} + +sub form { + my ($self, $c, $priority) = @_; - $c->stash->{body} = $c->model('DB::Body')->find($body_id) - or $c->detach( '/page_error_404_not_found' ); + # Otherwise, the form includes contacts for *all* bodies + $c->forward('/admin/fetch_contacts'); + my @all_contacts = map { + { value => $_->id, label => $_->category } + } $c->stash->{live_contacts}->all; + + my $opts = { + field_list => [ + '+contacts' => { options => \@all_contacts }, + ], + body_id => $c->stash->{body}->id, + }; + + my $form = FixMyStreet::App::Form::ResponsePriority->new(%$opts); + $c->stash(template => 'admin/responsepriorities/edit.html', form => $form); + $form->process(item => $priority, params => $c->req->params); + return unless $form->validated; + + $c->response->redirect($c->uri_for($self->action_for('index'))); } __PACKAGE__->meta->make_immutable; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm new file mode 100644 index 000000000..279ee695c --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm @@ -0,0 +1,102 @@ +package FixMyStreet::App::Controller::Admin::Roles; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use FixMyStreet::App::Form::Role; + +sub auto :Private { + my ($self, $c) = @_; + + my $user = $c->user; + if ($user->is_superuser) { + $c->stash(rs => $c->model('DB::Role')->search_rs({}, { + prefetch => 'body', + order_by => ['body.name', 'me.name'] + })); + } elsif ($user->from_body) { + $c->stash(rs => $user->from_body->roles->search_rs({}, { order_by => 'name' })); + } +} + +sub index :Path :Args(0) { + my ($self, $c) = @_; + + my $p = $c->cobrand->available_permissions; + my %labels; + foreach my $group (sort keys %$p) { + my $group_vals = $p->{$group}; + foreach (sort keys %$group_vals) { + $labels{$_} = $group_vals->{$_}; + } + } + + $c->stash( + roles => [ $c->stash->{rs}->all ], + labels => \%labels, + ); +} + +sub create :Local :Args(0) { + my ($self, $c) = @_; + + my $role = $c->stash->{rs}->new_result({}); + return $self->form($c, $role); +} + +sub item :PathPart('admin/roles') :Chained :CaptureArgs(1) { + my ($self, $c, $id) = @_; + + my $obj = $c->stash->{rs}->find($id) + or $c->detach('/page_error_404_not_found', []); + $c->stash(obj => $obj); +} + +sub edit :PathPart('') :Chained('item') :Args(0) { + my ($self, $c) = @_; + return $self->form($c, $c->stash->{obj}); +} + +sub form { + my ($self, $c, $role) = @_; + + if ($c->get_param('delete_role')) { + $role->delete; + $c->forward('/admin/log_edit', [ $role->id, 'role', 'delete' ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); + $c->detach; + } + + my $perms = []; + my $p = $c->cobrand->available_permissions; + foreach my $group (sort keys %$p) { + my $group_vals = $p->{$group}; + my @foo; + foreach (sort keys %$group_vals) { + push @foo, { value => $_, label => $group_vals->{$_} }; + } + push @$perms, { group => $group, options => \@foo }; + } + my $opts = { + field_list => [ + '+permissions' => { options => $perms }, + ], + }; + + if (!$c->user->is_superuser && $c->user->from_body) { + push @{$opts->{field_list}}, '+body', { inactive => 1 }; + $opts->{body_id} = $c->user->from_body->id; + } + + my $action = $role->in_storage ? 'edit' : 'add'; + my $form = FixMyStreet::App::Form::Role->new(%$opts); + $c->stash(template => 'admin/roles/form.html', form => $form); + $form->process(item => $role, params => $c->req->params); + return unless $form->validated; + + $c->forward('/admin/log_edit', [ $role->id, 'role', $action ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm index 5f82094d6..03b529a55 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm @@ -7,6 +7,52 @@ BEGIN { extends 'Catalyst::Controller'; } sub index : Path : Args(0) { my ( $self, $c ) = @_; return $c->cobrand->admin_stats() if $c->cobrand->moniker eq 'zurich'; + $c->forward('gather'); +} + +sub gather : Private { + my ($self, $c) = @_; + + $c->forward('state'); # Problem/update stats used on that page + $c->forward('/admin/fetch_all_bodies'); # For body stat + + my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction ); + + my %alert_counts = + map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all; + + $alert_counts{0} ||= 0; + $alert_counts{1} ||= 0; + + $c->stash->{alerts} = \%alert_counts; + + my $contacts = $c->model('DB::Contact')->summary_count(); + + my %contact_counts = + map { $_->state => $_->get_column('state_count') } $contacts->all; + + $contact_counts{confirmed} ||= 0; + $contact_counts{unconfirmed} ||= 0; + $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed}; + + $c->stash->{contacts} = \%contact_counts; + + my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction ); + + my %questionnaire_counts = map { + $_->get_column('answered') => $_->get_column('questionnaire_count') + } $questionnaires->all; + $questionnaire_counts{1} ||= 0; + $questionnaire_counts{0} ||= 0; + + $questionnaire_counts{total} = + $questionnaire_counts{0} + $questionnaire_counts{1}; + $c->stash->{questionnaires_pc} = + $questionnaire_counts{total} + ? sprintf( '%.1f', + $questionnaire_counts{1} / $questionnaire_counts{total} * 100 ) + : _('n/a'); + $c->stash->{questionnaires} = \%questionnaire_counts; } sub state : Local : Args(0) { diff --git a/perllib/FixMyStreet/App/Controller/Admin/Templates.pm b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm new file mode 100644 index 000000000..efff1b488 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm @@ -0,0 +1,181 @@ +package FixMyStreet::App::Controller::Admin::Templates; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Templates - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages for response templates + +=head1 METHODS + +=cut + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + my $user = $c->user; + + if ($user->is_superuser) { + $c->forward('/admin/fetch_all_bodies'); + } elsif ( $user->from_body ) { + $c->forward('load_template_body', [ $user->from_body->id ]); + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) ); + } else { + $c->detach( '/page_error_404_not_found', [] ); + } +} + +sub view : Path : Args(1) { + my ($self, $c, $body_id) = @_; + + $c->forward('load_template_body', [ $body_id ]); + + my @templates = $c->stash->{body}->response_templates->search( + undef, + { + order_by => 'title' + } + ); + + $c->stash->{response_templates} = \@templates; +} + +sub edit : Path : Args(2) { + my ( $self, $c, $body_id, $template_id ) = @_; + + $c->forward('load_template_body', [ $body_id ]); + + my $template; + if ($template_id eq 'new') { + $template = $c->stash->{body}->response_templates->new({}); + } + else { + $template = $c->stash->{body}->response_templates->find( $template_id ) + or $c->detach( '/page_error_404_not_found', [] ); + } + + $c->forward('/admin/fetch_contacts'); + my @contacts = $template->contacts->all; + my @live_contacts = $c->stash->{live_contacts}->all; + my %active_contacts = map { $_->id => 1 } @contacts; + my @all_contacts = map { { + id => $_->id, + category => $_->category_display, + active => $active_contacts{$_->id}, + email => $_->email, + group => $_->get_extra_metadata('group') // '', + } } @live_contacts; + $c->stash->{contacts} = \@all_contacts; + $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups; + + # bare block to use 'last' if form is invalid. + if ($c->req->method eq 'POST') { { + if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) { + $template->contact_response_templates->delete_all; + $template->delete; + $c->forward('/admin/log_edit', [ $template->id, 'template', 'delete' ]); + } else { + my @live_contact_ids = map { $_->id } @live_contacts; + my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; + my %new_contacts = map { $_ => 1 } @new_contact_ids; + for my $contact (@all_contacts) { + $contact->{active} = $new_contacts{$contact->{id}}; + } + + $template->title( $c->get_param('title') ); + my $query = { title => $template->title }; + if ($template->in_storage) { + $query->{id} = { '!=', $template->id }; + } + if ($c->stash->{body}->response_templates->search($query)->count) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{title} = _("There is already a template with that title."); + } + + $template->text( $c->get_param('text') ); + $template->state( $c->get_param('state') ); + $template->external_status_code( $c->get_param('external_status_code') ); + + if ( $template->state && $template->external_status_code ) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously."); + $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously."); + } + + $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 ); + if ($template->auto_response) { + my @check_contact_ids = @new_contact_ids; + # If the new template has not specific categories (i.e. it + # applies to all categories) then we need to check each of those + # category ids for existing auto-response templates. + if (!scalar @check_contact_ids) { + @check_contact_ids = @live_contact_ids; + } + my $query = { + 'auto_response' => 1, + 'contact.id' => [ @check_contact_ids, undef ], + -or => { + $template->state ? ('me.state' => $template->state) : (), + $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (), + }, + }; + if ($template->in_storage) { + $query->{'me.id'} = { '!=', $template->id }; + } + if ($c->stash->{body}->response_templates->search($query, { + join => { 'contact_response_templates' => 'contact' }, + })->count) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state."); + } + } + + last if $c->stash->{errors}; + + $template->update_or_insert; + $template->contact_response_templates->search({ + contact_id => { -not_in => \@new_contact_ids }, + })->delete; + foreach my $contact_id (@new_contact_ids) { + $template->contact_response_templates->find_or_create({ + contact_id => $contact_id, + }); + } + my $action = $template_id eq 'new' ? 'add' : 'edit'; + $c->forward('/admin/log_edit', [ $template->id, 'template', $action ]); + } + + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) ); + } } + + $c->stash->{response_template} = $template; +} + +sub load_template_body : Private { + my ($self, $c, $body_id) = @_; + + my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich'; + my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id); + + unless ( $zurich_user || $has_permission ) { + $c->detach( '/page_error_404_not_found', [] ); + } + + # Regular users can only view their own body's templates + if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) { + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->user->from_body->id ) ); + } + + $c->stash->{body} = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found', [] ); +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Triage.pm b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm new file mode 100644 index 000000000..428c35073 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm @@ -0,0 +1,163 @@ +package FixMyStreet::App::Controller::Admin::Triage; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Triage - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages for triaging reports. + +This allows reports to be triaged before being sent to the council. It works +by having a set of categories with a send_method of Triage which sets the report +state to 'for_triage'. Any reports with the state are then show on '/admin/triage' +which is available to users with the 'triage' permission. + +Clicking on reports on this list will then allow a user to change the category of +the report to one that has an alternative send method, which will trigger the report +to be resent. + +In order for this to work additional work needs to be done to the cobrand to only +display triageable categories to the user. + +=head1 METHODS + +=cut + +sub auto : Private { + my ( $self, $c ) = @_; + + unless ( $c->user->has_body_permission_to('triage') ) { + $c->detach('/page_error_403_access_denied', []); + } +} + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + # default sort to oldest + unless ( $c->get_param('sort') ) { + $c->set_param('sort', 'created-asc'); + } + $c->stash->{body} = $c->forward('/reports/body_find', [ $c->cobrand->council_area ]); + $c->forward( 'stash_report_filter_status' ); + $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]); + $c->forward( '/reports/load_and_group_problems' ); + $c->stash->{page} = 'reports'; # So the map knows to make clickable pins + + if ($c->get_param('ajax')) { + my $ajax_template = $c->stash->{ajax_template} || 'reports/_problem-list.html'; + $c->detach('/reports/ajax', [ $ajax_template ]); + } + + my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, { + columns => [ 'id', 'category', 'extra' ], + distinct => 1, + } )->all_sorted; + $c->stash->{filter_categories} = \@categories; + $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) }; + my $pins = $c->stash->{pins} || []; + + my %map_params = ( + latitude => @$pins ? $pins->[0]{latitude} : 0, + longitude => @$pins ? $pins->[0]{longitude} : 0, + area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ], + any_zoom => 1, + ); + FixMyStreet::Map::display_map( + $c, %map_params, pins => $pins, + ); +} + +sub stash_report_filter_status : Private { + my ( $self, $c ) = @_; + $c->stash->{filter_problem_states} = { 'for triage' => 1 }; + return 1; +} + +sub setup_categories : Private { + my ( $self, $c ) = @_; + + if ( $c->stash->{problem}->state eq 'for triage' ) { + $c->stash->{holding_options} = [ grep { $_->send_method && $_->send_method eq 'Triage' } @{$c->stash->{category_options}} ]; + $c->stash->{holding_categories} = { map { $_->category => 1 } @{$c->stash->{holding_options}} }; + $c->stash->{end_options} = [ grep { !$_->send_method || $_->send_method ne 'Triage' } @{$c->stash->{category_options}} ]; + $c->stash->{end_categories} = { map { $_->category => 1 } @{$c->stash->{end_options}} }; + delete $c->stash->{categories_hash}; + my %category_groups = (); + for my $category (@{$c->stash->{end_options}}) { + my $group = $category->{group} // $category->get_extra_metadata('group') // ['']; + # this could be an array ref or a string + my @groups = ref $group eq 'ARRAY' ? @$group : ($group); + push( @{$category_groups{$_}}, $category ) for @groups; + } + my @category_groups = (); + for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) { + push @category_groups, { name => $group, categories => $category_groups{$group} }; + } + $c->stash->{end_groups} = \@category_groups; + } + + return 1; +} + +sub update : Private { + my ($self, $c) = @_; + + my $problem = $c->stash->{problem}; + + my $current_category = $problem->category; + my $new_category = $c->get_param('category'); + + my $changed = $c->forward('/admin/reports/edit_category', [ $problem, 1 ] ); + + if ( $changed ) { + $c->stash->{problem}->update( { state => 'confirmed' } ); + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'triage' ] ); + + my $name = $c->user->moderating_user_name; + my $extra = { is_superuser => 1 }; + if ($c->user->from_body) { + delete $extra->{is_superuser}; + $extra->{is_body_user} = $c->user->from_body->id; + } + + $extra->{triage_report} = 1; + $extra->{holding_category} = $current_category; + $extra->{new_category} = $new_category; + + my $timestamp = \'current_timestamp'; + my $comment = $problem->add_to_comments( { + text => "Report triaged from $current_category to $new_category", + created => $timestamp, + confirmed => $timestamp, + user_id => $c->user->id, + name => $name, + mark_fixed => 0, + anonymous => 0, + state => 'confirmed', + problem_state => $problem->state, + extra => $extra, + whensent => \'current_timestamp', + } ); + + my @alerts = FixMyStreet::DB->resultset('Alert')->search( { + alert_type => 'new_updates', + parameter => $problem->id, + confirmed => 1, + } ); + + for my $alert (@alerts) { + my $alerts_sent = FixMyStreet::DB->resultset('AlertSent')->find_or_create( { + alert_id => $alert->id, + parameter => $comment->id, + } ); + } + } +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm index bcbc808ed..046e19126 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Users.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm @@ -27,37 +27,69 @@ Admin pages for editing users 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, - ] + if ($c->req->method eq 'POST') { + my @uids = $c->get_param_list('uid'); + my @role_ids = $c->get_param_list('roles'); + my $user_rs = FixMyStreet::DB->resultset("User")->search({ id => \@uids }); + foreach my $user ($user_rs->all) { + $user->admin_user_body_permissions->delete; + $user->user_roles->search({ + role_id => { -not_in => \@role_ids }, + })->delete; + foreach my $role (@role_ids) { + $user->user_roles->find_or_create({ + role_id => $role, + }); } - ); + } + $c->stash->{status_message} = _('Updated!'); + } + + my $search = $c->get_param('search'); + my $role = $c->get_param('role'); + if ($search || $role) { + my $users = $c->cobrand->users; + my $isearch; + if ($search) { + $search = $self->trim($search); + $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...> + $c->stash->{searched} = $search; + + $isearch = '%' . $search . '%'; + my $search_n = 0; + $search_n = int($search) if $search =~ /^\d+$/; + + $users = $users->search( + { + -or => [ + email => { ilike => $isearch }, + phone => { ilike => $isearch }, + name => { ilike => $isearch }, + from_body => $search_n, + ] + } + ); + } + if ($role) { + $c->stash->{role_selected} = $role; + $users = $users->search({ + role_id => $role, + }, { + join => 'user_roles', + }); + } + my @users = $users->all; $c->stash->{users} = [ @users ]; - $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]); + if ($search) { + $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 } }, @@ -67,6 +99,14 @@ sub index :Path : Args(0) { $c->stash->{users} = \@users; } + my $rs; + if ($c->user->is_superuser) { + $rs = $c->model('DB::Role')->search_rs({}, { join => 'body', order_by => ['body.name', 'me.name'] }); + } elsif ($c->user->from_body) { + $rs = $c->user->from_body->roles->search_rs({}, { order_by => 'name' }); + } + $c->stash->{roles} = [ $rs->all ]; + return 1; } @@ -113,9 +153,7 @@ sub add : Local : Args(0) { $c->stash->{field_errors}->{username} = _('User already exists'); } - return if %{$c->stash->{field_errors}}; - - my $user = $c->model('DB::User')->create( { + my $user = $c->model('DB::User')->new( { name => $c->get_param('name'), email => $email ? $email : undef, email_verified => $email && $email_v ? 1 : 0, @@ -127,28 +165,48 @@ sub add : Local : Args(0) { is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0, } ); $c->stash->{user} = $user; + + return if %{$c->stash->{field_errors}}; + $c->forward('user_cobrand_extra_fields'); - $user->update; + $user->insert; - $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'add' ] ); $c->flash->{status_message} = _("Updated!"); - $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); + $c->detach('post_edit_redirect', [ $user ]); } -sub edit : Path : Args(1) { - my ( $self, $c, $id ) = @_; +sub fetch_body_roles : Private { + my ($self, $c, $body ) = @_; - $c->forward('/auth/get_csrf_token'); + my $roles = $body->roles->search(undef, { order_by => 'name' }); + unless ($roles) { + delete $c->stash->{roles}; # Body doesn't have any roles + return; + } + + $c->stash->{roles} = [ $roles->all ]; +} + +sub user : Chained('/') PathPart('admin/users') : CaptureArgs(1) { + my ( $self, $c, $id ) = @_; my $user = $c->cobrand->users->find( { id => $id } ); $c->detach( '/page_error_404_not_found', [] ) unless $user; + $c->stash->{user} = $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; +sub edit : Chained('user') : PathPart('') : Args(0) { + my ( $self, $c ) = @_; + + $c->forward('/auth/get_csrf_token'); + + my $user = $c->stash->{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) ) { @@ -157,11 +215,11 @@ sub edit : Path : Args(1) { $c->forward('/admin/fetch_all_bodies'); $c->forward('/admin/fetch_body_areas', [ $user->from_body ]) if $user->from_body; + $c->forward('fetch_body_roles', [ $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->stash->{status_message} = $c->flash->{status_message}; } $c->forward('/auth/check_csrf_token') if $c->get_param('submit'); @@ -179,14 +237,12 @@ sub edit : Path : Args(1) { } 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; + $args{user_id} = $user->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'); @@ -228,19 +284,10 @@ sub edit : Path : Args(1) { 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 ) ); + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'merge' ] ); + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $existing_user->id ] ) ); } $user->email($email) if !$existing_email; @@ -270,26 +317,45 @@ sub edit : Path : Args(1) { # 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 ]); + $c->forward('fetch_body_roles', [ $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->user_roles->delete; $user->area_ids(undef); delete $c->stash->{areas}; + delete $c->stash->{roles}; 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({ + my %valid_roles = map { $_->id => 1 } @{$c->stash->{roles}}; + my @role_ids = grep { $valid_roles{$_} } $c->get_param_list('roles'); + if (@role_ids) { + # Roles take precedence over permissions + $user->admin_user_body_permissions->delete; + $user->user_roles->search({ + role_id => { -not_in => \@role_ids }, + })->delete; + foreach my $role (@role_ids) { + $user->user_roles->find_or_create({ + role_id => $role, + }); + } + } else { + $user->user_roles->delete; + 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 => $permission_type, - }); + permission_type => { -not_in => \@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, + }); + } } } @@ -299,35 +365,6 @@ sub edit : Path : Args(1) { $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; @@ -336,14 +373,15 @@ sub edit : Path : Args(1) { 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); + } else { + $user->unset_extra_metadata('categories'); } $user->update; - if ($edited) { - $c->forward( '/admin/log_edit', [ $id, 'user', 'edit' ] ); - } + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->flash->{status_message} = _("Updated!"); - return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); + + $c->detach('post_edit_redirect', [ $user ]); } if ( $user->from_body ) { @@ -358,8 +396,10 @@ sub edit : Path : Args(1) { id => $_->id, category => $_->category, active => $active_contacts{$_->id}, + group => $_->get_extra_metadata('group') // '', } } @live_contacts; $c->stash->{contacts} = \@all_contacts; + $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups; } # this goes after in case we've delete any alerts @@ -370,6 +410,50 @@ sub edit : Path : Args(1) { return 1; } +sub log : Chained('user') : PathPart('log') : Args(0) { + my ($self, $c) = @_; + + my $user = $c->stash->{user}; + + my $after = $c->get_param('after'); + + my %time; + foreach ($user->admin_logs->all) { + push @{$time{$_->whenedited->epoch}}, { type => 'log', date => $_->whenedited, log => $_ }; + } + foreach ($c->cobrand->problems->search({ extra => { like => '%contributed_by%' . $user->id . '%' } })->all) { + next unless $_->get_extra_metadata('contributed_by') == $user->id; + push @{$time{$_->created->epoch}}, { type => 'problemContributedBy', date => $_->created, obj => $_ }; + } + + foreach ($user->user_planned_reports->all) { + push @{$time{$_->added->epoch}}, { type => 'shortlistAdded', date => $_->added, obj => $_->report }; + push @{$time{$_->removed->epoch}}, { type => 'shortlistRemoved', date => $_->removed, obj => $_->report } if $_->removed; + } + + foreach ($user->problems->all) { + push @{$time{$_->created->epoch}}, { type => 'problem', date => $_->created, obj => $_ }; + } + + foreach ($user->comments->all) { + push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_}; + } + + $c->stash->{time} = \%time; +} + +sub post_edit_redirect : Private { + my ( $self, $c, $user ) = @_; + + # User may not be visible on this cobrand, e.g. if their from_body + # wasn't set. + if ( $c->cobrand->users->find( { id => $user->id } ) ) { + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $user->id ] ) ); + } else { + return $c->res->redirect( $c->uri_for_action( 'admin/users/index' ) ); + } +} + sub import :Local { my ( $self, $c, $id ) = @_; @@ -387,11 +471,9 @@ sub import :Local { 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); + $csv->header($fh); + while (my $row = $csv->getline_hr($fh)) { + my $email = lc Utils::trim_text($row->{email}); my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 }); if ($user->in_storage) { @@ -399,16 +481,29 @@ sub import :Local { next; } - $user->name($name); - $user->from_body($from_body || undef); - $user->update_or_insert; + $user->name($row->{name}); + $user->from_body($row->{from_body} || undef); + $user->password($row->{passwordhash}, 1) if $row->{passwordhash}; + $user->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, - }); + if ($row->{roles}) { + my @roles = split(/:/, $row->{roles}); + foreach my $role (@roles) { + $role = FixMyStreet::DB->resultset("Role")->find({ + body_id => $user->from_body->id, + name => $role, + }) or next; + $user->add_to_roles($role); + } + } else { + my @permissions = split(/:/, $row->{permissions}); + 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; @@ -497,7 +592,7 @@ 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->get_photoset->delete_cached(plus_updates => 1); $problem->update({ state => 'hidden' }); } my $updates = $user->comments->search({ state => { '!=' => 'hidden' } }); @@ -538,6 +633,7 @@ sub user_remove_account : Private { my ( $self, $c, $user ) = @_; $c->forward('user_logout_everywhere', [ $user ]); $user->anonymize_account; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('That user’s personal details have been removed.'); } @@ -565,6 +661,7 @@ sub ban : Private { $c->stash->{status_message} = _('User already in abuse list'); } else { $abuse->insert; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User added to abuse list'); } $c->stash->{username_in_abuse} = 1; @@ -575,6 +672,7 @@ sub ban : Private { $c->stash->{status_message} = _('User already in abuse list'); } else { $abuse->insert; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User added to abuse list'); } $c->stash->{username_in_abuse} = 1; @@ -596,6 +694,7 @@ sub unban : Private { my $abuse = $c->model('DB::Abuse')->search({ email => \@username }); if ( $abuse ) { $abuse->delete; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('user removed from abuse list'); } else { $c->stash->{status_message} = _('user not in abuse list'); @@ -625,6 +724,7 @@ sub flag : Private { } else { $user->flagged(1); $user->update; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User flagged'); } @@ -654,6 +754,7 @@ sub flag_remove : Private { } else { $user->flagged(0); $user->update; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User flag removed'); } diff --git a/perllib/FixMyStreet/App/Controller/Alert.pm b/perllib/FixMyStreet/App/Controller/Alert.pm index 1060c080b..a42e7203a 100644 --- a/perllib/FixMyStreet/App/Controller/Alert.pm +++ b/perllib/FixMyStreet/App/Controller/Alert.pm @@ -58,12 +58,15 @@ sub subscribe : Path('subscribe') : Args(0) { $c->detach('rss') if $c->get_param('rss'); + my $id = $c->get_param('id'); + $c->forward('/report/load_problem_or_display_error', [ $id ]) if $id; + # if it exists then it's been submitted so we should # go to subscribe email and let it work out the next step $c->detach('subscribe_email') if $c->get_param('rznvy') || $c->get_param('alert'); - $c->go('updates') if $c->get_param('id'); + $c->go('updates') if $id; # shouldn't get to here but if we have then do something sensible $c->go('index'); @@ -148,7 +151,7 @@ sub updates : Path('updates') : Args(0) { $c->forward('/auth/get_csrf_token'); $c->stash->{email} = $c->get_param('rznvy'); - $c->stash->{problem_id} = $c->get_param('id'); + $c->stash->{email} ||= $c->user->email if $c->user_exists; } =head2 confirm @@ -193,7 +196,7 @@ sub create_alert : Private { $alert->insert(); } - if ( $c->user && $c->user->id == $alert->user->id ) { + if ( $c->user_exists && ($c->user->id == $alert->user->id || $c->stash->{can_create_for_another})) { $alert->confirm(); } else { $alert->confirmed(0); @@ -211,13 +214,10 @@ Set up the options in the stash required to create a problem update alert sub set_update_alert_options : Private { my ( $self, $c ) = @_; - my $report_id = $c->get_param('id'); - return unless $report_id =~ /^[1-9]\d*$/; - my $options = { user => $c->stash->{alert_user}, alert_type => 'new_updates', - parameter => $report_id, + parameter => $c->stash->{problem}->id, }; $c->stash->{alert_options} = $options; @@ -283,7 +283,7 @@ sub send_confirmation_email : Private { my $user = $c->stash->{alert}->user; - # Superusers using 2FA can not log in by code + # People using 2FA can not log in by code $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa; my $token = $c->model("DB::Token")->create( @@ -340,16 +340,16 @@ sub process_user : Private { my ( $self, $c ) = @_; if ( $c->user_exists ) { - $c->stash->{alert_user} = $c->user->obj; - return; + $c->stash->{can_create_for_another} = $c->stash->{problem} + && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids); + if (!$c->stash->{can_create_for_another}) { + $c->stash->{alert_user} = $c->user->obj; + return; + } } - # Extract all the params to a hash to make them easier to work with - my %params = map { $_ => $c->get_param($_) } - ( 'rznvy' ); # , 'password_register' ); - - # cleanup the email address - my $email = $params{rznvy} ? lc $params{rznvy} : ''; + my $email = $c->get_param('rznvy'); + $email = $email ? lc $email : ''; $email =~ s{\s+}{}g; push @{ $c->stash->{errors} }, _('Please enter a valid email address') @@ -357,19 +357,6 @@ sub process_user : Private { my $alert_user = $c->model('DB::User')->find_or_new( { email => $email } ); $c->stash->{alert_user} = $alert_user; - -# # The user is trying to sign in. We only care about email from the params. -# if ( $c->get_param('submit_sign_in') ) { -# unless ( $c->forward( '/auth/sign_in', [ $email ] ) ) { -# $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. Please try again.'); -# return 1; -# } -# my $user = $c->user->obj; -# $c->stash->{alert_user} = $user; -# return 1; -# } -# -# $alert_user->password( $params{password_register} ); } =head2 setup_coordinate_rss_feeds diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index a09161494..af50f1883 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -231,27 +231,37 @@ 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; + $c->cobrand->call_hook(munge_report_new_bodies => \%bodies); # To match setup_categories_and_bodies in New.pm + my @list_of_names = map { $_->name } values %bodies; my $csv = Text::CSV->new(); $csv->combine(@list_of_names); + $c->stash->{around_bodies} = \@bodies; + $c->stash->{bodies_ids} = [ map { $_->id } @bodies]; $c->{stash}->{list_of_names_as_string} = $csv->string; + my $where = { body_id => [ keys %bodies ], }; + + my $cobrand_where = $c->cobrand->call_hook('munge_around_category_where', $where ); + if ( $cobrand_where ) { + $where = $cobrand_where; + } + my @categories = $c->model('DB::Contact')->not_deleted->search( - { - body_id => [ keys %bodies ], - }, + $where, { columns => [ 'category', 'extra' ], - order_by => [ 'category' ], distinct => 1 } - )->all; + )->all_sorted; $c->stash->{filter_categories} = \@categories; my %categories_mapped = map { $_->category => 1 } @categories; + $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups; my $categories = [ $c->get_param_list('filter_category', 1) ]; my %valid_categories = map { $_ => 1 } grep { $_ && $categories_mapped{$_} } @$categories; $c->stash->{filter_category} = \%valid_categories; + $c->cobrand->call_hook('munge_around_filter_category_list'); } sub map_features : Private { @@ -312,6 +322,7 @@ sub ajax : Path('/ajax') { my %valid_categories = map { $_ => 1 } $c->get_param_list('filter_category', 1); $c->stash->{filter_category} = \%valid_categories; + $c->cobrand->call_hook('munge_around_filter_category_list'); $c->forward('map_features', [ { bbox => $c->stash->{bbox} } ]); $c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]); @@ -321,12 +332,14 @@ sub nearby : Path { my ($self, $c) = @_; my $states = FixMyStreet::DB::Result::Problem->open_states(); - $c->forward('/report/_nearby_json', [ { + my $params = { latitude => $c->get_param('latitude'), longitude => $c->get_param('longitude'), categories => [ $c->get_param('filter_category') || () ], states => $states, - } ]); + }; + $c->cobrand->call_hook('around_nearby_filter', $params); + $c->forward('/report/_nearby_json', [ $params ]); } sub location_closest_address : Path('/ajax/closest') { @@ -416,7 +429,7 @@ sub lookup_by_ref : Private { external_id => $ref ]; - my $problems = $c->cobrand->problems->search( $criteria ); + my $problems = $c->cobrand->problems->search({ non_public => 0, -or => $criteria }); my $count = try { $problems->count; diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index c194045b9..cecfa318c 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -44,13 +44,12 @@ sub general : Path : Args(0) { # decide which action to take $c->detach('code_sign_in') if $clicked_sign_in_by_code || ($data_email && !$data_password); - if (!$data_username && !$data_password && !$data_email) { - $c->detach('social/facebook_sign_in') if $c->get_param('facebook_sign_in'); - $c->detach('social/twitter_sign_in') if $c->get_param('twitter_sign_in'); + if (!$data_username && !$data_password && !$data_email && $c->get_param('social_sign_in')) { + $c->forward('social/handle_sign_in'); } - $c->forward( 'sign_in', [ $data_username ] ) - && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); + $c->forward( 'sign_in', [ $data_username ] ) + && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); } @@ -68,6 +67,25 @@ sub forgot : Path('forgot') : Args(0) { $c->detach('code_sign_in'); } +sub expired : Path('expired') : Args(0) { + my ( $self, $c ) = @_; + + $c->detach('/page_error_403_access_denied', []) unless $c->user_exists; + + my $expiry = $c->cobrand->call_hook('password_expiry'); + $c->detach('/page_error_403_access_denied', []) unless $expiry; + + my $last_change = $c->user->get_extra_metadata('last_password_change') || 0; + my $midnight = int(time()/86400)*86400; + my $expired = $last_change + $expiry < $midnight; + $c->detach('/page_error_403_access_denied', []) unless $expired; + + $c->stash->{expired_password} = 1; + $c->stash->{template} = 'auth/create.html'; + return unless $c->req->method eq 'POST'; + $c->detach('code_sign_in', [ $c->user->email ]); +} + sub authenticate : Private { my ($self, $c, $type, $username, $password) = @_; return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password }); @@ -122,9 +140,9 @@ they come back with a token (which contains the email/phone). =cut sub code_sign_in : Private { - my ( $self, $c ) = @_; + my ( $self, $c, $override_username ) = @_; - my $username = $c->stash->{username} = $c->get_param('username') || ''; + my $username = $c->stash->{username} = $override_username || $c->get_param('username') || ''; my $parsed = FixMyStreet::SMS->parse_username($username); @@ -180,10 +198,13 @@ sub email_sign_in : Private { name => $c->get_param('name'), password => $user->password, }; - $token_data->{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $token_data->{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + + if ($c->get_param('oauth_need_email')) { + $token_data->{name} = $c->session->{oauth}{name} + if $c->session->{oauth}{name} && !$token_data->{name}; + $c->forward('set_oauth_token_data', [ $token_data ]); + } + if ($c->stash->{current_user}) { $token_data->{old_user_id} = $c->stash->{current_user}->id; $token_data->{r} = 'auth/change_email/success'; @@ -214,6 +235,14 @@ sub get_token : Private { return $data; } +sub set_oauth_token_data : Private { + my ( $self, $c, $token_data ) = @_; + + foreach (qw/facebook_id twitter_id oidc_id extra logout_redirect_uri change_password_uri/) { + $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_}; + } +} + =head2 token Handle the 'email_sign_in' tokens. Find the account for the email address @@ -231,11 +260,11 @@ sub token : Path('/M') : Args(1) { && (!$c->user_exists || $c->user->id ne $data->{old_user_id}); my $type = $data->{login_type} || 'email'; - $c->detach( '/auth/process_login', [ $data, $type ] ); + $c->detach( '/auth/process_login', [ $data, $type, $url_token ] ); } sub process_login : Private { - my ( $self, $c, $data, $type ) = @_; + my ( $self, $c, $data, $type, $url_token ) = @_; # sign out in case we are another user $c->logout(); @@ -247,8 +276,15 @@ sub process_login : Private { $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id}; - # Superusers using 2FA can not log in by code - $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa; + # People using 2FA need to supply a code + my $must_have_2fa = $c->cobrand->call_hook('must_have_2fa', $user) || ''; + if ($must_have_2fa ne 'skip') { + if ($user->has_2fa) { + $c->forward( 'token_2fa', [ $user, $url_token ] ); + } elsif ($c->cobrand->call_hook('must_have_2fa', $user)) { + $c->forward( 'signup_2fa', [ $user ] ); + } + } if ($data->{old_user_id}) { # Were logged in as old_user_id, want to switch to $user @@ -272,13 +308,74 @@ sub process_login : Private { $user->password( $data->{password}, 1 ) if $data->{password}; $user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id}; $user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id}; + $user->add_oidc_id( $data->{oidc_id} ) if $data->{oidc_id}; + $user->extra({ + %{ $user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; + $user->update_or_insert; $c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' ); + foreach (qw/logout_redirect_uri change_password_uri/) { + if ($data->{$_}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{$_} = $data->{$_}; + } + } + + # send the user to their page $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] ); } +=head2 token_2fa + +Used after clicking an email token link to request a 2FA code + +=cut + +sub token_2fa : Private { + my ($self, $c, $user, $url_token) = @_; + + return if $c->check_2fa($user->has_2fa); + + $c->stash->{form_action} = $c->req->path; + $c->stash->{token} = $url_token; + $c->stash->{template} = 'auth/2fa/form.html'; + $c->detach; +} + +sub signup_2fa : Private { + my ($self, $c, $user) = @_; + + $c->stash->{form_action} = $c->req->path; + $c->stash->{template} = 'auth/2fa/intro.html'; + my $action = $c->get_param('2fa_action') || ''; + + my $secret; + if ($action eq 'confirm') { + $secret = $c->get_param('secret32'); + if ($c->check_2fa($secret)) { + $user->set_extra_metadata('2fa_secret' => $secret); + $user->update; + $c->stash->{stage} = 'success'; + return; + } else { + $action = 'activate'; # Incorrect code, reshow + } + } + + if ($action eq 'activate') { + my $auth = FixMyStreet::Auth::GoogleAuth->new; + $c->stash->{qr_code} = $auth->qr_code($secret, $user->email, $c->cobrand->base_url); + $c->stash->{secret32} = $auth->secret32; + $c->stash->{stage} = 'activate'; + } + + $c->detach; +} + =head2 redirect_on_signin Used after signing in to take the person back to where they were. @@ -294,8 +391,11 @@ sub redirect_on_signin : Private { } unless ( $redirect ) { - $c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories }; - $redirect = 'my'; + my $inspector = $c->user->from_body && ( + scalar @{ $c->user->categories } || + scalar @{ $c->user->area_ids || [] } + ); + $redirect = $inspector ? 'my/inspector_redirect' : 'my'; } $redirect = 'my' if $redirect =~ /^admin/ && !$c->cobrand->admin_allow_user($c->user); if ( $c->cobrand->moniker eq 'zurich' ) { @@ -308,22 +408,6 @@ sub redirect_on_signin : Private { } } -=head2 redirect_to_categories - -Redirects the user to their body's reports page, prefiltered to whatever -categories this user has been assigned to. - -=cut - -sub redirect_to_categories : Private { - my ( $self, $c ) = @_; - - 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 } ) ); -} - =head2 redirect Used when trying to view a page that requires sign in when you're not. @@ -429,6 +513,12 @@ Log the user out. Tell them we've done so. sub sign_out : Local { my ( $self, $c ) = @_; $c->logout(); + + if ( $c->sessionid && $c->session->{oauth} && $c->session->{oauth}{logout_redirect_uri} ) { + $c->response->redirect($c->session->{oauth}{logout_redirect_uri}); + delete $c->session->{oauth}{logout_redirect_uri}; + $c->detach; + } } sub ajax_sign_in : Path('ajax/sign_in') { @@ -436,7 +526,8 @@ sub ajax_sign_in : Path('ajax/sign_in') { my $return = {}; if ( $c->forward( 'sign_in', [ $c->get_param('email') ] ) ) { - $return->{name} = $c->user->name; + $return->{name} = $c->user->name || '-'; # App currently requires something returned + $return->{success} = 1; } else { $return->{error} = 1; } @@ -509,6 +600,11 @@ sub check_auth : Local { return; } +sub two_factor_setup_success : Private { + my ($self, $c) = @_; + # Only here to be detached to after setup success +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm index 87aff2261..a89c6f539 100644 --- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm +++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm @@ -74,7 +74,8 @@ sub change_password : Path('/auth/change_password') { if ($c->user->password) { # we should have a usable password - save it to the user - $c->user->obj->update( { password => $new } ); + $c->user->obj->password($new); + $c->user->obj->update; $c->stash->{password_changed} = 1; } else { # Set up arguments for code sign in @@ -188,23 +189,38 @@ sub generate_token : Path('/auth/generate_token') { if ($c->get_param('generate_token')) { my $token = mySociety::AuthToken::random_token(); $c->user->set_extra_metadata('access_token', $token); + $c->user->update; $c->stash->{token_generated} = 1; } - if ($c->get_param('toggle_2fa') && $c->user->is_superuser) { - if ($has_2fa) { - $c->user->unset_extra_metadata('2fa_secret'); - $c->stash->{toggle_2fa_off} = 1; + my $action = $c->get_param('2fa_action') || ''; + $action = 'deactivate' if $c->get_param('2fa_deactivate'); + $action = 'activate' if $c->get_param('2fa_activate'); + $action = 'activate' if $action eq 'deactivate' && $has_2fa && $c->cobrand->call_hook('must_have_2fa', $c->user); + + my $secret; + if ($action eq 'deactivate') { + $c->user->unset_extra_metadata('2fa_secret'); + $c->user->update; + $c->stash->{toggle_2fa_off} = 1; + } elsif ($action eq 'confirm') { + $secret = $c->get_param('secret32'); + if ($c->check_2fa($secret)) { + $c->user->set_extra_metadata('2fa_secret', $secret); + $c->user->update; + $c->stash->{stage} = 'success'; + $has_2fa = 1; } else { - my $auth = Auth::GoogleAuth->new; - $c->stash->{qr_code} = $auth->qr_code(undef, $c->user->email, 'FixMyStreet'); - $c->stash->{secret32} = $auth->secret32; - $c->user->set_extra_metadata('2fa_secret', $auth->secret32); - $c->stash->{toggle_2fa_on} = 1; + $action = 'activate'; # Incorrect code, reshow } } - $c->user->update(); + if ($action eq 'activate') { + my $auth = FixMyStreet::Auth::GoogleAuth->new; + $c->stash->{qr_code} = $auth->qr_code($secret, $c->user->email, $c->cobrand->base_url); + $c->stash->{secret32} = $auth->secret32; + $c->stash->{stage} = 'activate'; + } } $c->stash->{has_2fa} = $has_2fa ? 1 : 0; diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm index 097cac984..06e67573f 100644 --- a/perllib/FixMyStreet/App/Controller/Auth/Social.pm +++ b/perllib/FixMyStreet/App/Controller/Auth/Social.pm @@ -6,6 +6,10 @@ BEGIN { extends 'Catalyst::Controller'; } use Net::Facebook::Oauth2; use Net::Twitter::Lite::WithAPIv1_1; +use OIDC::Lite::Client::WebServer::Azure; +use URI::Escape; + +use mySociety::AuthToken; =head1 NAME @@ -13,10 +17,26 @@ FixMyStreet::App::Controller::Auth::Social - Catalyst Controller =head1 DESCRIPTION -Controller for the Facebook/Twitter authentication. +Controller for the Facebook/Twitter/OpenID Connect authentication. =head1 METHODS +=head2 handle_sign_in + +Forwards to the appropriate (facebook|twitter|oidc)_sign_in method +based on the social_sign_in parameter + +=cut + +sub handle_sign_in : Private { + my ($self, $c) = @_; + + $c->detach('facebook_sign_in') if $c->get_param('social_sign_in') eq 'facebook'; + $c->detach('twitter_sign_in') if $c->get_param('social_sign_in') eq 'twitter'; + $c->detach('oidc_sign_in') if $c->get_param('social_sign_in') eq 'oidc'; + +} + =head2 facebook_sign_in Starts the Facebook authentication sequence. @@ -142,6 +162,166 @@ sub twitter_callback: Path('/auth/Twitter') : Args(0) { $c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]); } +sub oidc : Private { + my ($self, $c) = @_; + + my $config = $c->cobrand->feature('oidc_login'); + + OIDC::Lite::Client::WebServer::Azure->new( + id => $config->{client_id}, + secret => $config->{secret}, + authorize_uri => $config->{auth_uri}, + access_token_uri => $config->{token_uri}, + ); +} + +sub oidc_sign_in : Private { + my ( $self, $c ) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); + $c->detach( '/page_error_400_bad_request', [] ) unless $c->cobrand->feature('oidc_login'); + + my $oidc = $c->forward('oidc'); + my $nonce = $self->generate_nonce(); + my $url = $oidc->uri_to_redirect( + redirect_uri => $c->uri_for('/auth/OIDC'), + scope => 'openid', + state => 'login', + extra => { + response_mode => 'form_post', + nonce => $nonce, + }, + ); + + my %oauth; + $oauth{return_url} = $c->get_param('r'); + $oauth{detach_to} = $c->stash->{detach_to}; + $oauth{detach_args} = $c->stash->{detach_args}; + $oauth{nonce} = $nonce; + + # The OIDC endpoint may require a specific URI to be called to log the user + # out when they log out of FMS. + if ( my $redirect_uri = $c->cobrand->feature('oidc_login')->{logout_uri} ) { + $redirect_uri .= "?post_logout_redirect_uri="; + $redirect_uri .= URI::Escape::uri_escape( $c->uri_for('/auth/sign_out') ); + $oauth{logout_redirect_uri} = $redirect_uri; + } + + # The OIDC endpoint may provide a specific URI for changing the user's password. + if ( my $password_change_uri = $c->cobrand->feature('oidc_login')->{password_change_uri} ) { + $oauth{change_password_uri} = $oidc->uri_to_redirect( + uri => $password_change_uri, + redirect_uri => $c->uri_for('/auth/OIDC'), + scope => 'openid', + state => 'password_change', + extra => { + response_mode => 'form_post', + }, + ); + } + + $c->session->{oauth} = \%oauth; + $c->res->redirect($url); +} + +sub oidc_callback: Path('/auth/OIDC') : Args(0) { + my ( $self, $c ) = @_; + + my $oidc = $c->forward('oidc'); + + if ($c->get_param('error')) { + my $error_desc = $c->get_param('error_description'); + my $password_reset_uri = $c->cobrand->feature('oidc_login')->{password_reset_uri}; + if ($password_reset_uri && $error_desc =~ /^AADB2C90118:/) { + my $url = $oidc->uri_to_redirect( + uri => $password_reset_uri, + redirect_uri => $c->uri_for('/auth/OIDC'), + scope => 'openid', + state => 'password_reset', + extra => { + response_mode => 'form_post', + }, + ); + $c->res->redirect($url); + $c->detach; + } elsif ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') { + $c->flash->{flash_message} = _('Password change cancelled.'); + $c->res->redirect('/my'); + $c->detach; + } else { + $c->detach('oauth_failure'); + } + } + $c->detach('/page_error_400_bad_request', []) unless $c->get_param('code') && $c->get_param('state'); + + # After a password reset on the OIDC endpoint the user isn't properly logged + # in, so redirect them to the usual OIDC login process. + if ( $c->get_param('state') eq 'password_reset' ) { + # The user may have reset their password as part of the sign-in-during-report + # process, so preserve their report and redirect them to the right place + # if that happened. + if ( $c->session->{oauth} ) { + $c->stash->{detach_to} = $c->session->{oauth}{detach_to}; + $c->stash->{detach_args} = $c->session->{oauth}{detach_args}; + } + $c->detach('oidc_sign_in', []); + } + + # User may be coming back here after changing their password on the OIDC endpoint + if ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') { + $c->detach('/auth/profile/change_password_success', []); + } + + # The only other valid state param is 'login' at this point. + $c->detach('/page_error_400_bad_request', []) unless $c->get_param('state') eq 'login'; + + my $id_token; + eval { + $id_token = $oidc->get_access_token( + code => $c->get_param('code'), + ); + }; + if ($@) { + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); + } + + $c->detach('oauth_failure') unless $id_token; + + # sanity check the token audience is us... + $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{aud} eq $c->cobrand->feature('oidc_login')->{client_id}; + + # check that the nonce matches what we set in the user session + $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{nonce} eq $c->session->{oauth}{nonce}; + + # Some claims need parsing into a friendlier format + # XXX check how much of this is Westminster/Azure-specific + my $name = join(" ", $id_token->payload->{given_name}, $id_token->payload->{family_name}); + my $email = $id_token->payload->{email}; + # WCC Azure provides a single email address as an array for some reason + my $emails = $id_token->payload->{emails}; + if ($emails && @$emails) { + $email = $emails->[0]; + } + + # There's a chance that a user may have multiple OIDC logins, so build a namespaced uid to prevent collisions + my $uid = join(":", $c->cobrand->moniker, $c->cobrand->feature('oidc_login')->{client_id}, $id_token->payload->{sub}); + + # The cobrand may want to set values in the user extra field, e.g. a CRM ID + # which is passed to Open311 with reports made by this user. + my $extra = $c->cobrand->call_hook(oidc_user_extra => $id_token); + + $c->forward('oauth_success', [ 'oidc', $uid, $name, $email, $extra ]); +} + +# Just a wrapper around random_token to make mocking easier. +sub generate_nonce : Private { + my ($self, $c) = @_; + + return mySociety::AuthToken::random_token(); +} + + sub oauth_failure : Private { my ( $self, $c ) = @_; @@ -155,30 +335,64 @@ sub oauth_failure : Private { } sub oauth_success : Private { - my ($self, $c, $type, $uid, $name, $email) = @_; + my ($self, $c, $type, $uid, $name, $email, $extra) = @_; my $user; if ($email) { - # Only Facebook gets here + # Only Facebook & OIDC get here # We've got an ID and an email address + # Remove any existing mention of this ID - my $existing = $c->model('DB::User')->find( { facebook_id => $uid } ); - $existing->update( { facebook_id => undef } ) if $existing; - # Get or create a user, give it this Facebook ID + my $existing; + if ($type eq 'facebook') { + $existing = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + $existing->update( { $type . '_id' => undef } ) if $existing; + } elsif ( $type eq 'oidc' ) { + $existing = $c->model('DB::User')->find( { oidc_ids => \[ + '&& ?', [ oidc_ids => [ $uid ] ] + ] } ); + $existing->remove_oidc_id( $uid ) if $existing; + } + + # Get or create a user, give it this Facebook/OIDC ID $user = $c->model('DB::User')->find_or_new( { email => $email } ); - $user->facebook_id($uid); + if ( $type eq 'facebook' ) { + $user->facebook_id($uid); + } elsif ( $type eq 'oidc' ) { + $user->add_oidc_id($uid); + } $user->name($name); + if ($extra) { + $user->extra({ + %{ $user->get_extra() }, + %$extra + }); + } $user->in_storage() ? $user->update : $user->insert; } else { # We've got an ID, but no email - $user = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + if ($type eq 'oidc') { + $user = $c->model('DB::User')->find( { oidc_ids => \[ + '&& ?', [ oidc_ids => [ $uid ] ] + ] } ); + } else { + $user = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + } if ($user) { # Matching ID in our database $user->name($name); + if ($extra) { + $user->extra({ + %{ $user->get_extra() }, + %$extra + }); + } $user->update; } else { # No matching ID, store ID for use later $c->session->{oauth}{$type . '_id'} = $uid; + $c->session->{oauth}{name} = $name; + $c->session->{oauth}{extra} = $extra; $c->stash->{oauth_need_email} = 1; } } diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm index fb525fc1f..9ce89a9e2 100644 --- a/perllib/FixMyStreet/App/Controller/Contact.pm +++ b/perllib/FixMyStreet/App/Controller/Contact.pm @@ -7,6 +7,7 @@ BEGIN { extends 'Catalyst::Controller'; } use MIME::Base64; use mySociety::EmailUtil; use FixMyStreet::Email; +use FixMyStreet::Template::SafeString; =head1 NAME @@ -26,11 +27,15 @@ Functions to run on both GET and POST contact requests. sub auto : Private { my ($self, $c) = @_; - $c->forward('setup_request'); - $c->forward('determine_contact_type'); $c->forward('/auth/get_csrf_token'); } +sub begin : Private { + my ($self, $c) = @_; + $c->forward('/begin'); + $c->forward('setup_request'); +} + =head2 index Display contact us page @@ -39,6 +44,7 @@ Display contact us page sub index : Path : Args(0) { my ( $self, $c ) = @_; + $c->forward('determine_contact_type'); } =head2 submit @@ -50,6 +56,7 @@ Handle contact us form submission sub submit : Path('submit') : Args(0) { my ( $self, $c ) = @_; + $c->forward('determine_contact_type'); $c->res->redirect( '/contact' ) and return unless $c->req->method eq 'POST'; $c->go('index') unless $c->forward('validate'); @@ -87,11 +94,11 @@ sub determine_contact_type : Private { } elsif ($id) { $c->forward( '/report/load_problem_or_display_error', [ $id ] ); if ($update_id) { - my $update = $c->model('DB::Comment')->search( + my $update = $c->cobrand->updates->search( { - id => $update_id, + "me.id" => $update_id, problem_id => $id, - state => 'confirmed', + "me.state" => 'confirmed', } )->first; @@ -106,7 +113,14 @@ sub determine_contact_type : Private { $c->stash->{rejecting_report} = 1; } } elsif ( $c->cobrand->abuse_reports_only ) { - $c->detach( '/page_error_404_not_found' ); + # General enquiries replaces contact form if enabled + if ( $c->cobrand->can('setup_general_enquiries_stash') ) { + $c->res->redirect( '/contact/enquiry' ); + $c->detach; + return 1; + } else { + $c->detach( '/page_error_404_not_found' ); + } } return 1; @@ -185,6 +199,17 @@ sub prepare_params_for_email : Private { my $base_url = $c->cobrand->base_url(); my $admin_url = $c->cobrand->admin_base_url; + my $user = $c->cobrand->users->find( { email => $c->stash->{em} } ); + if ( $user ) { + $c->stash->{user_admin_url} = $admin_url . '/users/' . $user->id; + $c->stash->{user_reports_admin_url} = $admin_url . '/reports?search=' . $user->email; + + my $user_latest_problem = $user->latest_visible_problem(); + if ( $user_latest_problem) { + $c->stash->{user_latest_report_admin_url} = $admin_url . '/report_edit/' . $user_latest_problem->id; + } + } + if ( $c->stash->{update} ) { $c->stash->{problem_url} = $base_url . $c->stash->{update}->url; @@ -229,8 +254,9 @@ generally required to stash sub setup_request : Private { my ( $self, $c ) = @_; - $c->stash->{contact_email} = $c->cobrand->contact_email; - $c->stash->{contact_email} =~ s/\@/@/; + my $email = $c->cobrand->contact_email; + $email =~ s/\@/@/; + $c->stash->{contact_email} = FixMyStreet::Template::SafeString->new($email); for my $param (qw/em subject message/) { $c->stash->{$param} = $c->get_param($param); diff --git a/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm new file mode 100644 index 000000000..5b1c4980f --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm @@ -0,0 +1,119 @@ +package FixMyStreet::App::Controller::Contact::Enquiry; + +use Moose; +use namespace::autoclean; +use Path::Tiny; +use File::Copy; +use Digest::SHA qw(sha1_hex); +use File::Basename; + +BEGIN { extends 'Catalyst::Controller'; } + +sub auto : Private { + my ($self, $c) = @_; + + unless ( $c->cobrand->call_hook('setup_general_enquiries_stash') ) { + $c->res->redirect( '/' ); + $c->detach; + } +} + +# This needs to be defined here so /contact/begin doesn't get run instead. +sub begin : Private { + my ($self, $c) = @_; + + $c->forward('/begin'); +} + +sub index : Path : Args(0) { + my ( $self, $c, $preserve_session ) = @_; + + # Make sure existing files aren't lost if we're rendering this + # page as a result of validation error. + delete $c->session->{enquiry_files} unless $preserve_session; + + $c->stash->{field_errors}->{name} = _("Please enter your full name.") if $c->stash->{field_errors}->{name}; +} + +sub submit : Path('submit') : Args(0) { + my ( $self, $c ) = @_; + + unless ($c->req->method eq 'POST' && $c->forward("/report/new/check_form_submitted") ) { + $c->res->redirect( '/contact/enquiry' ); + return; + } + + # General enquiries are always private reports, and aren't + # located by the user on the map + $c->set_param('non_public', 1); + $c->set_param('pc', ''); + $c->set_param('skipped', 1); + + $c->forward('/report/new/initialize_report'); + $c->forward('/report/new/check_for_category'); + $c->forward('/auth/check_csrf_token'); + $c->forward('/report/new/process_report'); + $c->forward('/report/new/process_user'); + $c->forward('handle_uploads'); + $c->forward('/photo/process_photo'); + $c->go('index', [ 1 ]) unless $c->forward('/report/new/check_for_errors'); + $c->forward('/report/new/save_user_and_report'); + $c->forward('confirm_report'); + $c->stash->{success} = 1; + + # Don't want these lingering around for the next time. + delete $c->session->{enquiry_files}; +} + +sub confirm_report : Private { + my ( $self, $c ) = @_; + + my $report = $c->stash->{report}; + + # We don't ever want to modify an existing user, as general enquiries don't + # require any kind of email confirmation. + $report->user->insert unless $report->user->in_storage; + $report->confirm(); + $report->update; +} + +sub handle_uploads : Private { + my ( $self, $c ) = @_; + + # NB. For simplicity's sake this relies on the UPLOAD_DIR config key provided + # when using the FileSystem PHOTO_STORAGE_BACKEND. Should your FMS site not + # be using this storage backend, you must ensure that UPLOAD_DIR is set + # in order for general enquiries uploads to work. + my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS'); + my $dir = $cfg ? $cfg->{UPLOAD_DIR} : FixMyStreet->config('UPLOAD_DIR'); + $dir = path($dir, "enquiry_files")->absolute(FixMyStreet->path_to()); + $dir->mkpath; + + my $files = $c->session->{enquiry_files} || {}; + foreach ($c->req->upload) { + my $upload = $c->req->upload($_); + if ($upload->type !~ /^image/) { + # It's not a photo so remove it before /photo/process_photo rejects it + delete $c->req->uploads->{$_}; + + # For each file, copy it into place in a subdir of PHOTO_STORAGE_OPTIONS.UPLOAD_DIR + FixMyStreet::PhotoStorage::base64_decode_upload($c, $upload); + # Hash each file to get its filename, but preserve the file extension + # so content-type is correct when POSTing to Open311. + my ($p, $n, $ext) = fileparse($upload->filename, qr/\.[^.]*/); + my $key = sha1_hex($upload->slurp) . $ext; + my $out = path($dir, $key); + unless (copy($upload->tempname, $out)) { + $c->log->info('Couldn\'t copy temp file to destination: ' . $!); + $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again."); + return; + } + # Then store the file hashes in report->extra along with the original filenames + $files->{$key} = $upload->raw_basename; + } + } + $c->session->{enquiry_files} = $files; + $c->stash->{report}->set_extra_metadata(enquiry_files => $files); +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index bd60f8570..ad6c9ba98 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -70,15 +70,20 @@ sub check_page_allowed : Private { $c->detach( '/auth/redirect' ) unless $c->user_exists; - $c->detach( '/page_error_404_not_found' ) - unless $c->user->from_body || $c->user->is_superuser; + my $cobrand_body = $c->cobrand->can('council_area_id') ? $c->cobrand->body : undef; - my $body = $c->user->from_body; - if (!$body && $c->get_param('body')) { - # Must be a superuser, so allow query parameter if given - $body = $c->model('DB::Body')->find({ id => $c->get_param('body') }); + my $body; + if ($c->user->is_superuser) { + if ($c->get_param('body')) { + $body = $c->model('DB::Body')->find({ id => $c->get_param('body') }); + } else { + $body = $cobrand_body; + } + } elsif ($c->user->from_body && (!$cobrand_body || $cobrand_body->id == $c->user->from_body->id)) { + $body = $c->user->from_body; + } else { + $c->detach( '/page_error_404_not_found' ) } - return $body; } @@ -104,6 +109,7 @@ sub index : Path : Args(0) { $c->forward('/admin/fetch_contacts'); $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; + $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]); # See if we've had anything from the body dropdowns $c->stash->{category} = $c->get_param('category'); @@ -322,6 +328,7 @@ sub export_as_csv_updates : Private { objects => $c->stash->{objects_rs}->search_rs({}, { order_by => ['me.confirmed', 'me.id'], '+columns' => ['problem.bodies_str'], + cursor_page_size => 1000, }), headers => [ 'Report ID', 'Update ID', 'Date', 'Status', 'Problem state', @@ -342,8 +349,10 @@ sub export_as_csv : Private { my $csv = $c->stash->{csv} = { objects => $c->stash->{objects_rs}->search_rs({}, { - prefetch => 'comments', + join => 'comments', + '+columns' => ['comments.problem_state', 'comments.state', 'comments.confirmed', 'comments.mark_fixed'], order_by => ['me.confirmed', 'me.id'], + cursor_page_size => 1000, }), headers => [ 'Report ID', @@ -487,9 +496,6 @@ sub generate_csv : Private { } $csv->print($c->response, [ - map { - $_ = encode('UTF-8', $_) if $_; - } @{$hashref}{ @{$c->stash->{csv}->{columns}} }, @@ -497,6 +503,114 @@ sub generate_csv : Private { } } +sub heatmap : Local : Args(0) { + my ($self, $c) = @_; + + my $body = $c->stash->{body} = $c->forward('check_page_allowed'); + $c->detach( '/page_error_404_not_found' ) + unless $body && $c->cobrand->feature('heatmap'); + + $c->stash->{page} = 'reports'; # So the map knows to make clickable pins + + my @wards = $c->get_param_list('wards', 1); + $c->forward('/reports/ward_check', [ @wards ]) if @wards; + $c->forward('/reports/stash_report_filter_status'); + $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]); # Not actually used + my $parameters = $c->forward( '/reports/load_problems_parameters'); + + my $where = $parameters->{where}; + my $filter = $parameters->{filter}; + delete $filter->{rows}; + + $c->forward('heatmap_filters', [ $where ]); + + # Load the relevant stuff for the sidebar as well + my $problems = $c->cobrand->problems; + $problems = $problems->to_body($body); + $problems = $problems->search($where, $filter); + + $c->forward('heatmap_sidebar', [ $problems, $where ]); + + if ($c->get_param('ajax')) { + my @pins; + while ( my $problem = $problems->next ) { + push @pins, $problem->pin_data($c, 'reports'); + } + $c->stash->{pins} = \@pins; + $c->detach('/reports/ajax', [ 'dashboard/heatmap-list.html' ]); + } + + my $children = $c->stash->{body}->first_area_children; + $c->stash->{children} = $children; + $c->stash->{ward_hash} = { map { $_->{id} => 1 } @{$c->stash->{wards}} } if $c->stash->{wards}; + + $c->forward('/reports/setup_categories_and_map'); +} + +sub heatmap_filters :Private { + my ($self, $c, $where) = @_; + + # Wards + if ($c->user_exists) { + my @areas = @{$c->user->area_ids || []}; + # Want to get everything if nothing given in an ajax call + if (!$c->stash->{wards} && @areas) { + $c->stash->{wards} = [ map { { id => $_ } } @areas ]; + $where->{areas} = [ + map { { 'like', '%,' . $_ . ',%' } } @areas + ]; + } + } + + # Date range + my $start_default = DateTime->today(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(months => 1); + $c->stash->{start_date} = $c->get_param('start_date') || $start_default->strftime('%Y-%m-%d'); + $c->stash->{end_date} = $c->get_param('end_date'); + + my $range = FixMyStreet::DateRange->new( + start_date => $c->stash->{start_date}, + start_default => $start_default, + end_date => $c->stash->{end_date}, + formatter => $c->model('DB')->storage->datetime_parser, + ); + $where->{'me.confirmed'} = $range->sql; +} + +sub heatmap_sidebar :Private { + my ($self, $c, $problems, $where) = @_; + + $c->stash->{five_newest} = [ $problems->search(undef, { + rows => 5, + order_by => { -desc => 'confirmed' }, + })->all ]; + + $c->stash->{ten_oldest} = [ $problems->search({ + 'me.state' => [ FixMyStreet::DB::Result::Problem->open_states() ], + }, { + rows => 10, + order_by => 'lastupdate', + })->all ]; + + my $params = { map { my $n = $_; s/me\./problem\./; $_ => $where->{$n} } keys %$where }; + my $body = $c->stash->{body}; + + my @user; + push @user, $c->user->id if $c->user_exists; + push @user, $body->comment_user_id if $body->comment_user_id; + $params->{'me.user_id'} = { -not_in => \@user } if @user; + + my @c = $c->model('DB::Comment')->to_body($body)->search({ + %$params, + 'me.state' => 'confirmed', + }, { + columns => 'problem_id', + group_by => 'problem_id', + order_by => { -desc => \'max(me.confirmed)' }, + rows => 5, + })->all; + $c->stash->{five_commented} = [ map { $_->problem } @c ]; +} + =head1 AUTHOR Matthew Somerville diff --git a/perllib/FixMyStreet/App/Controller/Develop.pm b/perllib/FixMyStreet/App/Controller/Develop.pm index ae7122fa1..6a1c10b22 100755 --- a/perllib/FixMyStreet/App/Controller/Develop.pm +++ b/perllib/FixMyStreet/App/Controller/Develop.pm @@ -115,12 +115,34 @@ sub email_previewer : Path('/_dev/email') : Args(1) { } } elsif ($template eq 'questionnaire') { $vars->{created} = 'N weeks'; + } elsif ($template eq 'contact') { + $vars->{problem} = $c->model('DB::Problem')->search(undef, { rows => 1 } )->first; + $vars->{subject} = 'Please remove my details'; + $vars->{message} = 'I accidentally put my phone number, address, mothers maiden name, and facebook password in my most recent report!! Please remove it!!'; + $vars->{form_name} = $c->user->name; + $vars->{em} = $c->user->email; + $vars->{host} = $c->req->header('HOST'); + $vars->{ip} = $c->req->address; + $vars->{user_agent} = $c->req->user_agent; + $vars->{complaint} = sprintf( + "Complaint about report %d", + $vars->{problem}->id, + ); + $vars->{problem_url} = $c->cobrand->base_url() . '/report/' . $vars->{problem}->id; + $vars->{admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $vars->{problem}->id; + $vars->{user_admin_url} = $c->cobrand->admin_base_url . '/users/' . $c->user->id; + $vars->{user_reports_admin_url} = $c->cobrand->admin_base_url . '/reports?search=' . $c->user->email; + my $user_latest_problem = $c->user->latest_visible_problem(); + if ( $user_latest_problem ) { + $vars->{user_latest_report_admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $user_latest_problem->id; + } } my $email = $c->construct_email("$template.txt", $vars); # Look through the Email::MIME email for the text/html part, and any inline # images. Turn the images into data: URIs. + my $text = ''; my $html = ''; my %images; $email->walk_parts(sub { @@ -130,6 +152,8 @@ sub email_previewer : Path('/_dev/email') : Args(1) { (my $cid = $part->header('Content-ID')) =~ s/[<>]//g; (my $ct = $part->content_type) =~ s/;.*//; $images{$cid} = "$ct;base64," . $part->body_raw; + } elsif ($part->content_type =~ m[text/plain]i) { + $text = $part->body_str; } elsif ($part->content_type =~ m[text/html]i) { $html = $part->body_str; } @@ -139,7 +163,12 @@ sub email_previewer : Path('/_dev/email') : Args(1) { $html =~ s/cid:([^"]*)/data:$images{$1}/g; } - $c->response->body($html); + if ($c->get_param('text')) { + $c->response->header(Content_type => 'text/plain'); + $c->response->body($text); + } else { + $c->response->body($html); + } } =item problem_confirm_previewer diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm index 8d5b0b147..416fb942a 100644 --- a/perllib/FixMyStreet/App/Controller/Location.pm +++ b/perllib/FixMyStreet/App/Controller/Location.pm @@ -6,6 +6,7 @@ BEGIN {extends 'Catalyst::Controller'; } use Encode; use FixMyStreet::Geocode; +use Try::Tiny; use Utils; =head1 NAME @@ -107,6 +108,25 @@ sub determine_location_from_pc : Private { # pass errors back to the template $c->stash->{location_error_pc_lookup} = 1; $c->stash->{location_error} = $error; + + # Log failure in a log db + try { + my $dbfile = FixMyStreet->path_to('../data/analytics.sqlite'); + my $db = DBI->connect("dbi:SQLite:dbname=$dbfile", undef, undef, { PrintError => 0 }) or die "$DBI::errstr\n"; + my $sth = $db->prepare("INSERT INTO location_searches_with_no_results + (datetime, cobrand, geocoder, url, user_input) + VALUES (?, ?, ?, ?, ?)") or die $db->errstr . "\n"; + my $rv = $sth->execute( + POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime(time())), + $c->cobrand->moniker, + $c->cobrand->get_geocoder(), + $c->stash->{geocoder_url}, + $pc, + ); + } catch { + $c->log->debug("Unable to log to analytics.sqlite: $_"); + }; + return; } diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index 22869d531..f4143f0b4 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -214,7 +214,7 @@ sub report_moderate_hide : Private { if ($c->get_param('problem_hide')) { $problem->update({ state => 'hidden' }); - $problem->get_photoset->delete_cached; + $problem->get_photoset->delete_cached(plus_updates => 1); $c->res->redirect( '/' ); # Go directly to front-page $c->detach( 'report_moderate_audit', ['hide'] ); # break chain here. @@ -263,8 +263,8 @@ sub moderate_boolean : Private { if ($new != $old) { if ($thing eq 'photo') { - $object->$thing($new ? $original : undef); $object->get_photoset->delete_cached; + $object->$thing($new ? $original : undef); } else { $object->$thing($new); } @@ -298,7 +298,7 @@ sub moderate_location : Private { my $problem = $c->stash->{problem}; - my $moved = $c->forward('/admin/report_edit_location', [ $problem ]); + my $moved = $c->forward('/admin/reports/edit_location', [ $problem ]); if (!$moved) { # New lat/lon isn't valid, show an error $c->stash->{moderate_errors} ||= []; @@ -315,11 +315,11 @@ sub moderate_category : Private { return unless $c->get_param('category'); # The admin category editing needs to know all the categories etc - $c->forward('/admin/categories_for_point'); + $c->forward('/admin/reports/categories_for_point'); my $problem = $c->stash->{problem}; - my $changed = $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + my $changed = $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] ); # It might need to set_report_extras in future if ($changed) { return 'category'; diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm index ed890ad82..3328caac0 100644 --- a/perllib/FixMyStreet/App/Controller/My.pm +++ b/perllib/FixMyStreet/App/Controller/My.pm @@ -45,6 +45,34 @@ sub my : Path : Args(0) { $c->forward('setup_page_data'); } + +=head2 inspector_redirect + +A convenience redirect to the /reports/ page pre-filtered to the +inspector's body, areas & categories. + +=cut + +sub inspector_redirect : Local : Args(0) { + my ( $self, $c ) = @_; + + my $categories = $c->user->categories_string; + my $area_ids = $c->user->area_ids || []; + my $body = $c->user->from_body; + + $c->detach('/page_error_404_not_found') unless $body && ($categories || @$area_ids); + + if (@$area_ids) { + my $ids_string = join ",", @$area_ids; + my $areas = mySociety::MaPit::call('areas', [ $ids_string ]); + $c->stash->{wards} = [ values %$areas ]; + } + + $c->stash->{body} = $body; + $c->set_param('filter_category', $categories) if $categories; + $c->detach('/reports/redirect_body'); +} + sub planned : Local : Args(0) { my ( $self, $c ) = @_; @@ -161,11 +189,12 @@ sub setup_page_data : Private { my @categories = $c->stash->{problems_rs}->search({ state => [ FixMyStreet::DB::Result::Problem->visible_states() ], }, { - columns => [ 'category' ], + columns => [ 'category', 'bodies_str', 'extra' ], distinct => 1, order_by => [ 'category' ], } )->all; $c->stash->{filter_categories} = \@categories; + $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups; my $pins = $c->stash->{pins}; FixMyStreet::Map::display_map( diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm index d50d0d03f..adb3de14d 100644 --- a/perllib/FixMyStreet/App/Controller/Offline.pm +++ b/perllib/FixMyStreet/App/Controller/Offline.pm @@ -1,5 +1,9 @@ package FixMyStreet::App::Controller::Offline; + +use Image::Size; +use JSON::MaybeXS; use Moose; +use Path::Tiny; use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } @@ -10,35 +14,108 @@ FixMyStreet::App::Controller::Offline - Catalyst Controller =head1 DESCRIPTION -Offline pages Catalyst Controller. +Offline pages Catalyst Controller - service worker handling =head1 METHODS =cut -sub have_appcache : Private { +sub service_worker : Path("/service-worker.js") { my ($self, $c) = @_; - return $c->user_exists && $c->user->has_body_permission_to('planned_reports') - && !($c->user->is_superuser && FixMyStreet->staging_flag('enable_appcache', 0)); + $c->res->content_type('application/javascript'); } -sub manifest : Path("/offline/appcache.manifest") { +sub fallback : Local { my ($self, $c) = @_; - unless ($c->forward('have_appcache')) { - $c->response->status(404); - $c->response->body('NOT FOUND'); - } - $c->res->content_type('text/cache-manifest; charset=utf-8'); - $c->res->header(Cache_Control => 'no-cache, no-store'); } -sub appcache : Path("/offline/appcache") { +sub manifest: Path("/.well-known/manifest.webmanifest") { my ($self, $c) = @_; - $c->detach('/page_error_404_not_found', []) if keys %{$c->req->params}; - unless ($c->forward('have_appcache')) { - $c->response->status(404); - $c->response->body('NOT FOUND'); + $c->res->content_type('application/manifest+json'); + + my $data = { + name => $c->stash->{manifest_theme}->{name}, + short_name => $c->stash->{manifest_theme}->{short_name}, + background_color => $c->stash->{manifest_theme}->{background_colour}, + theme_color => $c->stash->{manifest_theme}->{theme_colour}, + icons => $c->stash->{manifest_theme}->{icons}, + lang => $c->stash->{lang_code}, + display => "minimal-ui", + start_url => "/?pwa", + scope => "/", + }; + if ($c->cobrand->can('manifest')) { + $data = { %$data, %{$c->cobrand->manifest} }; + } + + my $json = encode_json($data); + $c->res->body($json); +} + +sub _stash_manifest_theme : Private { + my ($self, $c, $cobrand) = @_; + + $c->stash->{manifest_theme} = $c->forward('_find_manifest_theme', [ $cobrand ]); +} + +sub _find_manifest_theme : Private { + my ($self, $c, $cobrand, $ignore_cache_and_defaults) = @_; + + my $key = "manifest_theme:$cobrand"; + # ignore_cache_and_defaults is only used in the admin, so no harm bypassing cache + my $manifest_theme = $ignore_cache_and_defaults ? undef : Memcached::get($key); + + unless ( $manifest_theme ) { + my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $cobrand }); + unless ( $theme ) { + $theme = $c->model('DB::ManifestTheme')->new({ + name => $c->stash->{site_name}, + short_name => $c->stash->{site_name}, + background_colour => '#ffffff', + theme_colour => '#ffd000', + }); + } + + my @icons; + my $uri = '/theme/' . $cobrand; + my $theme_path = path(FixMyStreet->path_to('web' . $uri)); + $theme_path->visit( + sub { + my ($x, $y, $typ) = Image::Size::imgsize($_->stringify); + push @icons, { + src => join('/', $uri, $_->basename), + sizes => join('x', $x, $y), + type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '', + }; + } + ); + + unless (@icons || $ignore_cache_and_defaults) { + push @icons, + { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" }, + { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" }; + } + + $manifest_theme = { + icons => \@icons, + background_colour => $theme->background_colour, + theme_colour => $theme->theme_colour, + name => $theme->name, + short_name => $theme->short_name, + }; + + unless ($ignore_cache_and_defaults) { + Memcached::set($key, $manifest_theme); + } } + + return $manifest_theme; +} + +sub _clear_manifest_theme_cache : Private { + my ($self, $c, $cobrand ) = @_; + + Memcached::delete("manifest_theme:$cobrand"); } __PACKAGE__->meta->make_immutable; diff --git a/perllib/FixMyStreet/App/Controller/Open311.pm b/perllib/FixMyStreet/App/Controller/Open311.pm index 841330e92..b4b5d5e3a 100644 --- a/perllib/FixMyStreet/App/Controller/Open311.pm +++ b/perllib/FixMyStreet/App/Controller/Open311.pm @@ -111,8 +111,6 @@ sub get_discovery : Private { { 'contact' => ["Send email to $contact_email."], 'changeset' => [$prod_changeset], - # XXX rewrite to match - 'key_service' => ["Read access is open to all according to our \u003Ca href='/open_data' target='_blank'\u003Eopen data license\u003C/a\u003E. For write access either: 1. return the 'guid' cookie on each call (unique to each client) or 2. use an api key from a user account which can be generated here: http://seeclickfix.com/register The unversioned url will always point to the latest supported version."], 'max_requests' => [ $c->config->{OPEN311_LIMIT} || 1000 ], 'endpoints' => [ { @@ -195,9 +193,7 @@ sub get_services : Private { ); } $c->forward( 'format_output', [ { - 'services' => [ { - 'service' => \@services - } ] + 'services' => \@services } ] ); } @@ -291,9 +287,7 @@ sub output_requests : Private { } $c->forward( 'format_output', [ { - 'requests' => [ { - 'request' => \@problemlist - } ] + service_requests => \@problemlist } ] ); } @@ -429,7 +423,21 @@ sub format_output : Private { $c->res->body( encode_json($hashref) ); } elsif ('xml' eq $format) { $c->res->content_type('application/xml; charset=utf-8'); - $c->res->body( XMLout($hashref, RootName => undef, NoAttr => 1 ) ); + my $group_tags = { + services => 'service', + attributes => 'attribute', + values => 'value', + service_requests => 'request', + errors => 'error', + service_request_updates => 'request_update', + }; + $c->res->body( XMLout($hashref, + KeyAttr => {}, + GroupTags => $group_tags, + SuppressEmpty => undef, + RootName => undef, + NoAttr => 1, + ) ); } else { $c->detach( 'error', [ sprintf(_('Invalid format %s specified.'), $format) diff --git a/perllib/FixMyStreet/App/Controller/Open311/Updates.pm b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm new file mode 100644 index 000000000..105400a8a --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm @@ -0,0 +1,88 @@ +package FixMyStreet::App::Controller::Open311::Updates; + +use utf8; +use Moose; +use namespace::autoclean; +use Open311; +use Open311::GetServiceRequestUpdates; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Open311::Updates - Catalyst Controller + +=head1 DESCRIPTION + +=head1 METHODS + +=cut + +sub receive : Regex('^open311/v2/servicerequestupdates.(xml|json)$') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{format} = $c->req->captures->[0]; + + $c->detach('bad_request', [ 'POST' ]) unless $c->req->method eq 'POST'; + + my $body; + if ($c->cobrand->can('council_area_id')) { + $body = $c->cobrand->body; + } else { + $body = $c->model('DB::Body')->find({ id => $c->get_param('jurisdiction_id') }); + } + $c->detach('bad_request', ['jurisdiction_id']) unless $body; + my $user = $body->comment_user; + + my $key = $c->get_param('api_key') || ''; + my $token = $c->cobrand->feature('open311_token') || ''; + $c->detach('bad_request', [ 'api_key' ]) unless $key && $key eq $token; + + my $request = { + media_url => $c->get_param('media_url'), + external_status_code => $c->get_param('external_status_code'), + }; + foreach (qw(service_request_id update_id updated_datetime status description)) { + $request->{$_} = $c->get_param($_) || $c->detach('bad_request', [ $_ ]); + } + + my %open311_conf = ( + endpoint => $body->endpoint, + api_key => $body->api_key, + jurisdiction => $body->jurisdiction, + extended_statuses => $body->send_extended_statuses, + ); + + my $cobrand = $body->get_cobrand_handler; + $cobrand->call_hook(open311_config_updates => \%open311_conf) + if $cobrand; + + my $open311 = Open311->new(%open311_conf); + my $updates = Open311::GetServiceRequestUpdates->new( + system_user => $user, + current_open311 => $open311, + current_body => $body, + ); + + my $p = $updates->find_problem($request); + $c->detach('bad_request', [ 'not found' ]) unless $p; + + my $comment = $p->comments->search( { external_id => $request->{update_id} } )->first; + $c->detach('bad_request', [ 'already exists' ]) if $comment; + + $comment = $updates->process_update($request, $p); + + my $data = { service_request_updates => { update_id => $comment->id } }; + + $c->forward('/open311/format_output', [ $data ]); +} + +sub bad_request : Private { + my ($self, $c, $comment) = @_; + $c->response->status(400); + $c->forward('/open311/format_output', [ { errors => { code => 400, description => "Bad request: $comment" } } ]); +} + +__PACKAGE__->meta->make_immutable; + +1; + diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm index 7b536a292..3408d5e35 100644 --- a/perllib/FixMyStreet/App/Controller/Photo.pm +++ b/perllib/FixMyStreet/App/Controller/Photo.pm @@ -39,10 +39,11 @@ sub during :LocalRegex('^(temp|fulltemp)\.([0-9a-f]{40}\.(?:jpeg|png|gif|tiff))$ $size = $size eq 'temp' ? 'default' : 'full'; my $photo = $photoset->get_image_data(size => $size, default => $c->cobrand->default_photo_resize); + $c->stash->{non_public} = 0; $c->forward( 'output', [ $photo ] ); } -sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|png|gif|tiff)$') { +sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp|og))?\.(?:jpeg|png|gif|tiff)$') { my ( $self, $c ) = @_; my ( $is_update, $id, $photo_number, $size ) = @{ $c->req->captures }; @@ -51,11 +52,12 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg| my $item; if ( $is_update ) { - ($item) = $c->model('DB::Comment')->search( { - id => $id, - state => 'confirmed', - photo => { '!=', undef }, - } ); + ($item) = $c->cobrand->updates->search( { + 'me.id' => $id, + 'me.state' => 'confirmed', + 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ], + 'me.photo' => { '!=', undef }, + }, { prefetch => 'problem' }); } else { ($item) = $c->cobrand->problems->search( { id => $id, @@ -68,6 +70,19 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg| $c->detach( 'no_photo' ) unless $c->cobrand->allow_photo_display($item, $photo_number); # Should only be for reports, not updates + my $problem = $is_update ? $item->problem : $item; + $c->stash->{non_public} = $problem->non_public; + + if ($c->stash->{non_public}) { + my $body_ids = $problem->bodies_str_ids; + # Check permission + $c->detach('no_photo') unless $c->user_exists; + $c->detach('no_photo') unless $c->user->is_superuser + || $c->user->id == $problem->user->id + || $c->user->has_permission_to('report_inspect', $body_ids) + || $c->user->has_permission_to('report_mark_private', $body_ids); + } + my $photo; $photo = $item->get_photoset ->get_image_data( num => $photo_number, size => $size, default => $c->cobrand->default_photo_resize ) @@ -80,10 +95,12 @@ sub output : Private { my ( $self, $c, $photo ) = @_; # Save to file - 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; + if (!FixMyStreet->config('LOGIN_REQUIRED') && !$c->stash->{non_public}) { + 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} ); diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm index d2b0bf3f4..ab6117ae4 100755 --- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm +++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm @@ -290,9 +290,9 @@ sub display : Private { my $problem = $c->stash->{questionnaire}->problem; - $c->stash->{updates} = [ $c->model('DB::Comment')->search( - { problem_id => $problem->id, state => 'confirmed' }, - { order_by => 'confirmed' } + $c->stash->{updates} = [ $c->cobrand->updates->search( + { problem_id => $problem->id, "me.state" => 'confirmed' }, + { order_by => 'me.confirmed' } )->all ]; $c->stash->{page} = 'questionnaire'; diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 7f798f4f4..72f96013a 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -1,5 +1,6 @@ package FixMyStreet::App::Controller::Report; +use utf8; use Moose; use namespace::autoclean; use JSON::MaybeXS; @@ -85,11 +86,15 @@ sub display :PathPart('') :Chained('id') :Args(0) { $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 report_mark_private/ ] ); + [ qw/report_inspect report_edit_category report_edit_priority report_mark_private triage/ ] ); if (any { $_ } values %$permissions) { $c->stash->{template} = 'report/inspect.html'; $c->forward('inspect'); } + + if ($c->user_exists && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids)) { + $c->stash->{email} = $c->user->email; + } } sub moderate_report :PathPart('moderate') :Chained('id') :Args(0) { @@ -155,10 +160,20 @@ sub load_problem_or_display_error : Private { $c->stash->{problem} = $problem; my $permissions = $c->stash->{_permissions} = $c->forward( 'check_has_permission_to', [ 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})) ) { + + # If someone has clicked a unique token link in an email to them + my $from_email = $c->sessionid && $c->flash->{alert_to_reporter} && $c->flash->{alert_to_reporter} == $problem->id; + + my $allowed = 0; + $allowed = 1 if $from_email; + $allowed = 1 if $c->user_exists && $c->user->id == $problem->user->id; + $allowed = 1 if $permissions->{report_inspect} || $permissions->{report_mark_private}; + + unless ($allowed) { + my $url = '/auth?r=report/' . $problem->id; $c->detach( '/page_error_403_access_denied', - [ sprintf(_('That report cannot be viewed on %s.'), $c->stash->{site_name}) ] + [ sprintf(_('Sorry, you don’t have permission to do that. If you are the problem reporter, or a member of staff, please <a href="%s">sign in</a> to view this report.'), $url) ] ); } } @@ -181,9 +196,9 @@ sub load_problem_or_display_error : Private { sub load_updates : Private { my ( $self, $c ) = @_; - my $updates = $c->model('DB::Comment')->search( - { problem_id => $c->stash->{problem}->id, state => 'confirmed' }, - { order_by => [ 'confirmed', 'id' ] } + my $updates = $c->cobrand->updates->search( + { problem_id => $c->stash->{problem}->id, "me.state" => 'confirmed' }, + { order_by => [ 'me.confirmed', 'me.id' ] } ); my $questionnaires_still_open = $c->model('DB::Questionnaire')->search( @@ -293,7 +308,8 @@ sub format_problem_for_display : Private { delete $report_hashref->{created}; delete $report_hashref->{confirmed}; - my $content = encode_json( + my $json = JSON::MaybeXS->new( convert_blessed => 1, utf8 => 1 ); + my $content = $json->encode( { report => $report_hashref, updates => $c->cobrand->updates_as_hashref( $problem, $c ), @@ -354,8 +370,6 @@ sub delete :Chained('id') :Args(0) { $p->lastupdate( \'current_timestamp' ); $p->update; - $p->user->update_reputation(-1); - $c->model('DB::AdminLog')->create( { user => $c->user->obj, admin_user => $c->user->from_body->name, @@ -372,13 +386,19 @@ sub inspect : Private { my $problem = $c->stash->{problem}; my $permissions = $c->stash->{_permissions}; - $c->forward('/admin/categories_for_point'); + $c->forward('/admin/reports/categories_for_point'); $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}}); + if ($c->cobrand->can('body')) { + my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories( + $c->stash->{contacts}, + body_id => $c->cobrand->body->id + ); $c->stash->{priorities_by_category} = $priorities_by_category; - my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}}); + my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories( + $c->stash->{contacts}, + body_id => $c->cobrand->body->id + ); $c->stash->{templates_by_category} = $templates_by_category; } @@ -394,12 +414,18 @@ sub inspect : Private { $c->stash->{max_detailed_info_length} = $c->cobrand->max_detailed_info_length; - if ( $c->get_param('save') ) { + if ( $c->get_param('triage') ) { + $c->forward('/auth/check_csrf_token'); + $c->forward('/admin/triage/update'); + my $redirect_uri = $c->uri_for( '/admin/triage' ); + $c->log->debug( "Redirecting to: " . $redirect_uri ); + $c->res->redirect( $redirect_uri ); + } + elsif ( $c->get_param('save') ) { $c->forward('/auth/check_csrf_token'); my $valid = 1; my $update_text = ''; - my $reputation_change = 0; my %update_params = (); if ($permissions->{report_inspect}) { @@ -435,7 +461,7 @@ sub inspect : Private { $problem->confirmed( \'current_timestamp' ); } if ( $problem->state eq 'hidden' ) { - $problem->get_photoset->delete_cached; + $problem->get_photoset->delete_cached(plus_updates => 1); } if ( $problem->state eq 'duplicate') { if (my $duplicate_of = $c->get_param('duplicate_of')) { @@ -454,8 +480,6 @@ sub inspect : Private { $update_params{problem_state} = $problem->state; my $state = $problem->state; - $reputation_change = 1 if $c->cobrand->reputation_increment_states->{$state}; - $reputation_change = -1 if $c->cobrand->reputation_decrement_states->{$state}; # If an inspector has changed the state, subscribe them to # updates @@ -466,19 +490,14 @@ sub inspect : Private { }; $c->user->create_alert($problem->id, $options); } - - # If the state has been changed to action scheduled and they've said - # they want to raise a defect, consider the report to be inspected. - if ($problem->state eq 'action scheduled' && $c->get_param('raise_defect') && !$problem->get_extra_metadata('inspected')) { - $update_params{extra} = { 'defect_raised' => 1 }; - $problem->set_extra_metadata( inspected => 1 ); - $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'inspected' ] ); - } } $problem->non_public($c->get_param('non_public') ? 1 : 0); + if ($problem->non_public) { + $problem->get_photoset->delete_cached(plus_updates => 1); + } - if ( !$c->forward( '/admin/report_edit_location', [ $problem ] ) ) { + if ( !$c->forward( '/admin/reports/edit_location', [ $problem ] ) ) { # New lat/lon isn't valid, show an error $valid = 0; $c->stash->{errors} ||= []; @@ -486,10 +505,11 @@ sub inspect : Private { } if ($permissions->{report_inspect} || $permissions->{report_edit_category}) { - $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] ); if ($c->stash->{update_text}) { - $update_text .= "\n\n" . $c->stash->{update_text}; + $update_text .= "\n\n" if $update_text; + $update_text .= $c->stash->{update_text}; } # The new category might require extra metadata (e.g. pothole size), so @@ -511,22 +531,12 @@ sub inspect : Private { } } - 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); - } $problem->lastupdate( \'current_timestamp' ); $problem->update; + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'edit' ] ); if ($update_text || %update_params) { my $timestamp = \'current_timestamp'; if (my $saved_at = $c->get_param('saved_at')) { @@ -590,7 +600,13 @@ sub inspect : Private { sub map :Chained('id') :Args(0) { my ($self, $c) = @_; - my $image = $c->stash->{problem}->static_map; + my %params; + if ( $c->get_param('inline_duplicate') ) { + $params{full_size} = 1; + $params{zoom} = 5; + } + + my $image = $c->stash->{problem}->static_map(%params); $c->res->content_type($image->{content_type}); $c->res->body($image->{data}); } @@ -639,7 +655,7 @@ sub _nearby_json :Private { my $list_html = $c->render_fragment( 'report/nearby.html', - { reports => $nearby } + { reports => $nearby, inline_maps => $c->get_param("inline_maps") ? 1 : 0 } ); my $json = { pins => \@pins }; @@ -665,6 +681,33 @@ sub check_has_permission_to : Private { return \%permissions; }; + +sub stash_category_groups : Private { + my ( $self, $c, $contacts, $combine_multiple ) = @_; + + my %category_groups = (); + for my $category (@$contacts) { + my $group = $category->{group} // $category->get_extra_metadata('group') // ['']; + # this could be an array ref or a string + my @groups = ref $group eq 'ARRAY' ? @$group : ($group); + if (scalar @groups > 1 && $combine_multiple) { + @groups = sort @groups; + $category->{group} = \@groups; + push( @{$category_groups{_('Multiple Groups')}}, $category ); + } else { + push( @{$category_groups{$_}}, $category ) for @groups; + } + } + + my @category_groups = (); + for my $group ( grep { $_ ne _('Other') && $_ ne _('Multiple Groups') } sort keys %category_groups ) { + push @category_groups, { name => $group, categories => $category_groups{$group} }; + } + push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')}); + push @category_groups, { name => _('Multiple Groups'), categories => $category_groups{_('Multiple Groups')} } if ($category_groups{_('Multiple Groups')}); + $c->stash->{category_groups} = \@category_groups; +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index 8944a9307..fc1a78cd5 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -4,10 +4,10 @@ use Moose; use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } +use utf8; use Encode; use List::MoreUtils qw(uniq); use List::Util 'first'; -use POSIX 'strcoll'; use HTML::Entities; use Path::Class; use Utils; @@ -102,18 +102,18 @@ sub report_new : Path : Args(0) { $c->stash->{template} = "report/new/fill_in_details.html"; $c->forward('setup_categories_and_bodies'); $c->forward('setup_report_extra_fields'); - $c->forward('generate_map'); $c->forward('check_for_category'); + $c->forward('setup_report_extras'); # deal with the user and report and check both are happy - return unless $c->forward('check_form_submitted'); + $c->detach('generate_map') unless $c->forward('check_form_submitted'); $c->forward('/auth/check_csrf_token'); $c->forward('process_report'); $c->forward('process_user'); $c->forward('/photo/process_photo'); - return unless $c->forward('check_for_errors'); + $c->detach('generate_map') unless $c->forward('check_for_errors'); $c->forward('save_user_and_report'); $c->forward('redirect_or_confirm_creation'); } @@ -142,6 +142,7 @@ sub report_new_ajax : Path('mobile') : Args(0) { $c->forward('setup_categories_and_bodies'); $c->forward('setup_report_extra_fields'); + $c->forward('check_for_category'); $c->forward('process_report'); $c->forward('process_user'); $c->forward('/photo/process_photo'); @@ -157,7 +158,7 @@ sub report_new_ajax : Path('mobile') : Args(0) { my $report = $c->stash->{report}; if ( $report->confirmed ) { - $c->forward( 'create_reporter_alert' ); + $c->forward( 'create_related_things' ); $c->stash->{ json_response } = { success => 1, report => $report->id }; } else { $c->forward( 'send_problem_confirm_email' ); @@ -201,6 +202,10 @@ 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 %display_names = map { + my $name = $_->cobrand_name; + ( $_->name ne $name ) ? ( $_->name => $name ) : (); + } values %{$c->stash->{bodies}}; my $contribute_as = {}; if ($c->user_exists) { my @bodies = keys %{$c->stash->{bodies}}; @@ -227,6 +232,7 @@ sub report_form_ajax : Path('ajax') : Args(0) { category => $category, extra_name_info => $extra_name_info, titles_list => $extra_titles_list, + %display_names ? (display_names => \%display_names) : (), %$contribute_as ? (contribute_as => $contribute_as) : (), $top_message ? (top_message => $top_message) : (), unresponsive => $c->stash->{unresponsive}->{ALL} || '', @@ -247,42 +253,54 @@ sub category_extras_ajax : Path('category_extras') : Args(0) { $c->forward('setup_report_extra_fields'); $c->forward('check_for_category'); - my $category = $c->stash->{category} || ""; - $category = '' if $category eq _('-- Pick a category --'); - - $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $category ]); + $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $c->stash->{category} ]); $c->forward('send_json_response'); } sub by_category_ajax_data : Private { my ($self, $c, $type, $category) = @_; - my $generate; - 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; + my $bodies = []; + my $vars = {}; + if ($category) { + $bodies = $c->forward('contacts_to_bodies', [ $category ]); + @bodies = @$bodies; + $vars->{list_of_names} = [ map { $_->cobrand_name } @bodies ]; + } else { + @bodies = values %{$c->stash->{bodies_to_list}}; } - 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 $non_public = $c->stash->{non_public_categories}->{$category}; + my $anon_button = ($c->cobrand->allow_anonymous_reports($category) eq 'button'); my $body = { - bodies => $list_of_names, + bodies => [ map { $_->name } @bodies ], + $non_public ? ( non_public => JSON->true ) : (), + $anon_button ? ( allow_anonymous => JSON->true ) : (), }; - if ($generate) { + if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) { + my $disable_form = $c->forward('disable_form_message'); + $body->{disable_form} = $disable_form if %$disable_form; + + # Remove the full disable_form extras, as included in disable form output + @{$c->stash->{category_extras}->{$c->stash->{category}}} = grep { + !$_->{disable_form} || $_->{disable_form} ne 'true' + } @{$c->stash->{category_extras}->{$c->stash->{category}}}; + } + + if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or + $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) { $body->{category_extra} = $c->render_fragment('report/new/category_extras.html', $vars); $body->{category_extra_json} = $c->forward('generate_category_extra_json'); - } my $unresponsive = $c->stash->{unresponsive}->{$category}; $unresponsive ||= $c->stash->{unresponsive}->{ALL} || '' if $type eq 'one'; # unresponsive must return empty string if okay, as that's what mobile app checks + # councils_text.html must be rendered if it differs from the default output, + # which currently means for unresponsive and non_public categories. if ($type eq 'one' || ($type eq 'all' && $unresponsive)) { $body->{unresponsive} = $unresponsive; # Check for no bodies here, because if there are any (say one @@ -292,10 +310,41 @@ sub by_category_ajax_data : Private { $body->{councils_text_private} = $c->render_fragment( 'report/new/councils_text_private.html'); } } + if ($non_public) { + $body->{councils_text} = $c->render_fragment( 'report/new/councils_text.html', $vars); + } return $body; } +sub disable_form_message : Private { + my ( $self, $c ) = @_; + + my %out; + + # do not set disable form message if they are a staff user + return \%out if $c->cobrand->call_hook('staff_ignore_form_disable_form'); + + foreach (@{$c->stash->{category_extras}->{$c->stash->{category}}}) { + if ($_->{disable_form} && $_->{disable_form} eq 'true') { + $out{all} .= ' ' if $out{all}; + $out{all} .= $_->{description}; + } elsif (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) { + my %category; + foreach my $opt (@{$_->{values}}) { + if ($opt->{disable}) { + $category{message} = $opt->{disable_message} || $_->{datatype_description}; + $category{code} = $_->{code}; + push @{$category{answers}}, $opt->{key}; + } + } + push @{$out{questions}}, \%category if %category; + } + } + + return \%out; +} + =head2 report_import Action to accept report creations from iPhones and other mobile apps. URL is @@ -309,8 +358,7 @@ sub report_import : Path('/import') { # If this is not a POST then just print out instructions for using page return unless $c->req->method eq 'POST'; - # anything else we return is plain text - $c->res->content_type('text/plain; charset=utf-8'); + my $format = $c->get_param('web') ? 'web' : 'text'; my %input = map { $_ => $c->get_param($_) || '' } ( @@ -363,8 +411,14 @@ sub report_import : Path('/import') { # if we have errors then we should bail out if (@errors) { - my $body = join '', map { "ERROR:$_\n" } @errors; - $c->res->body($body); + if ($format eq 'web') { + $c->stash->{input} = \%input; + $c->stash->{errors} = \@errors; + } else { + my $body = join '', map { "ERROR:$_\n" } @errors; + $c->res->content_type('text/plain; charset=utf-8'); + $c->res->body($body); + } return; } @@ -420,13 +474,13 @@ 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'); + if ($format eq 'web') { $c->stash->{template} = 'email_sent.html'; $c->stash->{email_type} = 'problem'; - return 1; + } else { + $c->res->content_type('text/plain; charset=utf-8'); + $c->res->body('SUCCESS'); } - $c->res->body('SUCCESS'); return 1; } @@ -476,9 +530,11 @@ sub initialize_report : Private { ->first; if ($report) { - # log the problem creation user in to the site - $c->authenticate( { email => $report->user->email, email_verified => 1 }, - 'no_password' ); + # log the problem creation user in to the site, if not already logged in + if (!$c->user_exists || $c->user->email ne $report->user->email) { + $c->authenticate( { email => $report->user->email, email_verified => 1 }, + 'no_password' ); + } # save the token to delete at the end $c->stash->{partial_token} = $token if $report; @@ -637,12 +693,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 $contacts # - = $c # - ->model('DB::Contact') # - ->active - ->search( { 'me.body_id' => [ keys %bodies ] }, { prefetch => 'body' } ); - my @contacts = $c->cobrand->categories_restriction($contacts)->all; + $c->cobrand->call_hook(munge_report_new_bodies => \%bodies); + + my $contacts = $c->model('DB::Contact')->for_new_reports($c, \%bodies); + my @contacts = $c->cobrand->categories_restriction($contacts)->all_sorted; + + $c->cobrand->call_hook(munge_report_new_contacts => \@contacts); # variables to populate my %bodies_to_list = (); # Bodies with categories assigned @@ -650,6 +706,8 @@ sub setup_categories_and_bodies : Private { my %category_extras = (); # extra fields to fill in for open311 my %category_extras_hidden = (); # whether all of a category's fields are hidden + my %category_extras_notices = + (); # whether all of a category's fields are simple notices and not inputs my %non_public_categories = (); # categories for which the reports are not public $c->stash->{unresponsive} = {}; @@ -668,15 +726,6 @@ sub setup_categories_and_bodies : Private { $c->stash->{unresponsive}{$k} = { map { $_ => 1 } keys %bodies }; } - # keysort does not appear to obey locale so use strcoll (see i18n.t) - @contacts = sort { strcoll( $a->category, $b->category ) } @contacts; - - # Get defect types for inspectors - if ($c->cobrand->can('council_area_id')) { - my $category_defect_types = FixMyStreet::App->model('DB::DefectType')->by_categories($c->cobrand->council_area_id, @contacts); - $c->stash->{category_defect_types} = $category_defect_types; - } - my %seen; foreach my $contact (@contacts) { @@ -691,6 +740,16 @@ sub setup_categories_and_bodies : Private { } else { $category_extras_hidden{$contact->category} = $all_hidden; } + + my $all_notices = (grep { + ( $_->{variable} || '' ) ne 'false' + && !$c->cobrand->category_extra_hidden($_) + } @$metas) ? 0 : 1; + if (exists($category_extras_notices{$contact->category})) { + $category_extras_notices{$contact->category} &&= $all_notices; + } else { + $category_extras_notices{$contact->category} = $all_notices; + } } $non_public_categories{ $contact->category } = 1 if $contact->non_public; @@ -712,15 +771,17 @@ sub setup_categories_and_bodies : Private { push @category_options, $seen{_('Other')} if $seen{_('Other')}; } - $c->cobrand->call_hook(munge_category_list => \@category_options, \@contacts, \%category_extras); + $c->cobrand->call_hook(munge_report_new_category_list => \@category_options, \@contacts, \%category_extras); # put results onto stash for display $c->stash->{bodies} = \%bodies; $c->stash->{contacts} = \@contacts; $c->stash->{bodies_to_list} = \%bodies_to_list; + $c->stash->{bodies_ids} = [ map { $_ } keys %bodies ]; $c->stash->{category_options} = \@category_options; $c->stash->{category_extras} = \%category_extras; $c->stash->{category_extras_hidden} = \%category_extras_hidden; + $c->stash->{category_extras_notices} = \%category_extras_notices; $c->stash->{non_public_categories} = \%non_public_categories; $c->stash->{extra_name_info} = $first_area->{id} == COUNCIL_ID_BROMLEY ? 1 : 0; @@ -736,20 +797,7 @@ sub setup_categories_and_bodies : Private { $c->stash->{missing_details_bodies} = \@missing_details_bodies; $c->stash->{missing_details_body_names} = \@missing_details_body_names; - if ( $c->cobrand->call_hook('enable_category_groups') ) { - my %category_groups = (); - for my $category (@category_options) { - my $group = $category->{group} // $category->get_extra_metadata('group') // ''; - push @{$category_groups{$group}}, $category; - } - - my @category_groups = (); - for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) { - push @category_groups, { name => $group, categories => $category_groups{$group} }; - } - push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')}); - $c->stash->{category_groups} = \@category_groups; - } + $c->forward('/report/stash_category_groups', [ \@category_options ]) if $c->cobrand->enable_category_groups; } sub setup_report_extra_fields : Private { @@ -794,10 +842,17 @@ sub process_user : Private { # 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_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously'); + my $anon_fallback = $c->cobrand->allow_anonymous_reports eq '1' && !$c->user_exists && !$params{username}; + if ($anon_button || $anon_fallback) { my $anon_details = $c->cobrand->anonymous_account; - $params{username} ||= $anon_details->{email}; - $params{name} ||= $anon_details->{name}; + my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} }); + $user->name($anon_details->{name}); + $report->user($user); + $report->name($user->name); + $c->stash->{no_reporter_alert} = 1; + $c->stash->{contributing_as_anonymous_user} = 1; + return 1; } # The user is already signed in. Extra bare block for 'last'. @@ -805,9 +860,11 @@ sub process_user : Private { my $user = $c->user->obj; if ($c->stash->{contributing_as_another_user}) { - # Act as if not logged in (and it will be auto-confirmed later on) - $report->user(undef); - last; + if ($params{username} || $params{phone}) { + # Act as if not logged in (and it will be auto-confirmed later on) + $report->user(undef); + last; + } } $report->user( $user ); @@ -821,6 +878,8 @@ sub process_user : Private { $report->name($name); $user->name($name) unless $user->name; $c->stash->{no_reporter_alert} = 1; + } elsif ($c->stash->{contributing_as_another_user}) { + $c->stash->{no_reporter_alert} = 1; } return 1; @@ -854,7 +913,7 @@ sub process_user : Private { oauth_report => { $report->get_inflated_columns } }; unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) { - $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.'); + $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.'); return 1; } my $user = $c->user->obj; @@ -907,12 +966,12 @@ sub process_report : Private { 'title', 'detail', 'pc', # 'detail_size', 'may_show_name', # - 'category', # 'subcategory', # 'partial', # 'service', # 'non_public', ); + $params{category} = $c->stash->{category}; # load the report my $report = $c->stash->{report}; @@ -929,6 +988,13 @@ sub process_report : Private { $c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies}); $c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies}); } + # This is also done in process_user, but is needed here for anonymous() just below + my $anon_button = $c->cobrand->allow_anonymous_reports($params{category}) eq 'button' && $c->get_param('report_anonymously'); + if ($anon_button) { + $c->stash->{contributing_as_anonymous_user} = 1; + $c->stash->{contributing_as_body} = undef; + $c->stash->{contributing_as_another_user} = undef; + } # set some simple bool values (note they get inverted) if ($c->stash->{contributing_as_body}) { @@ -971,12 +1037,20 @@ sub process_report : Private { } # 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. + # and if we find a matching one then only send to that. 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'; + if ($body) { + # Drop the contacts down to those in this body + # (potentially none for e.g. Highways England) + # so that set_report_extras doesn't error when + # there are 'missing' extra fields + @contacts = grep { $_->body->id == $body->id } @contacts; + $body->id; + } else { + '-1'; + } } else { my $contact_options = {}; $contact_options->{do_not_send} = [ $c->get_param_list('do_not_send', 1) ]; @@ -1065,18 +1139,26 @@ sub contacts_to_bodies : Private { [ map { $_->body } @contacts ]; } +sub setup_report_extras : Private { + my ($self, $c) = @_; + + # report_meta is used by the templates to fill in the extra field values + my $extra = $c->stash->{report}->get_extra_fields; + $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @$extra }; +} + sub set_report_extras : Private { my ($self, $c, $contacts, $param_prefix) = @_; $param_prefix ||= ""; - my @metalist = map { [ $_->get_metadata_for_input, $param_prefix ] } @$contacts; + my @metalist = map { [ $_->get_metadata_for_storage, $param_prefix ] } @$contacts; push @metalist, map { [ $_->get_extra_fields, "extra[" . $_->id . "]" ] } @{$c->stash->{report_extra_fields}}; 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)) { + if ( lc( $field->{required} || '' ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) { unless ( $c->get_param($param_prefix . $field->{code}) ) { $c->stash->{field_errors}->{ 'x' . $field->{code} } = _('This information is required'); } @@ -1112,12 +1194,13 @@ sub check_for_errors : Private { $c->stash->{field_errors} ||= {}; my %field_errors = $c->cobrand->report_check_for_errors( $c ); + my $report = $c->stash->{report}; + # Zurich, we don't care about title or name # There is no title, and name is optional if ( $c->cobrand->moniker eq 'zurich' ) { delete $field_errors{title}; delete $field_errors{name}; - my $report = $c->stash->{report}; $report->title( Utils::cleanup_text( substr($report->detail, 0, 25) ) ); # We only want to validate the phone number web requests (where the @@ -1137,8 +1220,13 @@ sub check_for_errors : Private { delete $field_errors{name}; } + # If we're making an anonymous report, we do not care about the name field + if ( $c->stash->{contributing_as_anonymous_user} ) { + delete $field_errors{name}; + } + # if using social login then we don't care about other errors - $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in'); + $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0; if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; delete $field_errors{username}; @@ -1162,9 +1250,8 @@ sub check_for_errors : Private { if ( $c->cobrand->allow_anonymous_reports ) { my $anon_details = $c->cobrand->anonymous_account; - my $report = $c->stash->{report}; $report->user->email(undef) if $report->user->email eq $anon_details->{email}; - $report->name(undef) if $report->name eq $anon_details->{name}; + $report->name(undef) if $report->name && $report->name eq $anon_details->{name}; } return; @@ -1180,10 +1267,8 @@ sub tokenize_user : Private { password => $report->user->password, title => $report->user->title, }; - $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ]) + if $c->get_param('oauth_need_email'); } sub send_problem_confirm_email : Private { @@ -1278,7 +1363,7 @@ sub process_confirmation : Private { ); # Subscribe problem reporter to email updates - $c->forward( '/report/new/create_reporter_alert' ); + $c->forward( '/report/new/create_related_things' ); # log the problem creation user in to the site if ( $data->{name} || $data->{password} ) { @@ -1291,7 +1376,21 @@ sub process_confirmation : Private { for (qw(name title facebook_id twitter_id)) { $problem->user->$_( $data->{$_} ) if $data->{$_}; } + $problem->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id}; + $problem->user->extra({ + %{ $problem->user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; + $problem->user->update; + + # Make sure extra oauth state is restored, if applicable + foreach (qw/logout_redirect_uri change_password_uri/) { + if ($data->{$_}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{$_} = $data->{$_}; + } + } } if ($problem->user->email_verified) { $c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' ); @@ -1351,11 +1450,7 @@ sub save_user_and_report : Private { $c->stash->{detach_to} = '/report/new/oauth_callback'; $c->stash->{detach_args} = [$token->token]; - if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/social/facebook_sign_in'); - } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/social/twitter_sign_in'); - } + $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in'); } # Save or update the user if appropriate @@ -1373,6 +1468,13 @@ sub save_user_and_report : Private { $report->confirm(); } elsif ($c->stash->{contributing_as_anonymous_user}) { $report->set_extra_metadata( contributed_as => 'anonymous_user' ); + if ( $c->user_exists && $c->user->from_body ) { + # If a staff user has clicked the 'report anonymously' button then + # there would be no record of who that staff member was as we've + # used the cobrand's anonymous_account for the report. In this case + # record the staff user ID in the report metadata. + $report->set_extra_metadata( contributed_by => $c->user->id ); + } $report->confirm(); } elsif ( !$report->user->in_storage ) { # User does not exist. @@ -1450,9 +1552,61 @@ sub generate_map : Private { sub check_for_category : Private { my ( $self, $c ) = @_; - $c->stash->{category} = $c->get_param('category'); + my $category = $c->get_param('category') || $c->stash->{report}->category || ''; + $category = '' if $category eq _('Loading...') || $category eq _('-- Pick a category --'); + # Just check to see if the filter had an option + $category ||= $c->get_param('filter_category') || ''; + $c->stash->{category} = $category; - return 1; + # Bit of a copy of set_report_extras, because we need the results here, but + # don't want to run all of that fn until later as it e.g. alters field + # errors at that point. Also, the report might already have some answers in + # too if e.g. gone via social login... TODO Improve this? + my $extra = $c->stash->{report}->get_extra_fields; + my %current = map { $_->{name} => $_ } @$extra; + + my @contacts = grep { $_->category eq $category } @{$c->stash->{contacts}}; + my @metalist = map { @{$_->get_metadata_for_storage} } @contacts; + my @extra; + foreach my $field (@metalist) { + push @extra, { + name => $field->{code}, + description => $field->{description}, + value => $c->get_param($field->{code}) || $current{$field->{code}}{value} || '', + }; + } + $c->stash->{report}->set_extra_fields( @extra ); + + # Work out if the selected category (or category extra question answer) should lead + # to a message being shown not to use the form + if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) { + my $disable_form_messages = $c->forward('disable_form_message'); + if ($disable_form_messages->{all}) { + $c->stash->{disable_form_message} = $disable_form_messages->{all}; + } elsif (my $questions = $disable_form_messages->{questions}) { + foreach my $question (@$questions) { + my $answer = $c->get_param($question->{code}); + my $message = $question->{message}; + if ($answer) { + foreach (@{$question->{answers}}) { + if ($answer eq $_) { + $c->stash->{disable_form_message} = $message; + } + } + } + } + if (!$c->stash->{disable_form_message}) { + $c->stash->{have_disable_qn_to_answer} = 1; + } + } + } + + if ($c->get_param('submit_category_part_only') || $c->stash->{disable_form_message}) { + # If we've clicked the first-part category button (no-JS only probably), + # or the category submitted will be showing a disabled form message, + # we only want to reshow the form + $c->stash->{force_form_not_submitted} = 1; + } } =head2 redirect_or_confirm_creation @@ -1469,8 +1623,9 @@ sub redirect_or_confirm_creation : Private { # If confirmed send the user straight there. if ( $report->confirmed ) { # Subscribe problem reporter to email updates - $c->forward( 'create_reporter_alert' ); + $c->forward( 'create_related_things' ); if ($c->stash->{contributing_as_another_user} && $report->user->email + && $report->user->id != $c->user->id && !$c->cobrand->report_sent_confirmation_email) { $c->send_email( 'other-reported.txt', { to => [ [ $report->user->email, $report->name ] ], @@ -1491,7 +1646,7 @@ sub redirect_or_confirm_creation : Private { return 1; } - # Superusers using 2FA can not log in by code + # People using 2FA can not log in by code $c->detach( '/page_error_403_access_denied', [] ) if $report->user->has_2fa; # otherwise email or text a confirm token to them. @@ -1510,12 +1665,51 @@ sub redirect_or_confirm_creation : Private { $c->log->info($report->user->id . ' created ' . $report->id . ", $thing sent, " . ($c->stash->{token_data}->{password} ? 'password set' : 'password not set')); } -sub create_reporter_alert : Private { +sub create_related_things : Private { my ( $self, $c ) = @_; + my $problem = $c->stash->{report}; + + # If there is a special template, create a comment using that + foreach my $body (values %{$problem->bodies}) { + my $user = $body->comment_user or next; + + my %open311_conf = ( + endpoint => $body->endpoint || '', + api_key => $body->api_key || '', + jurisdiction => $body->jurisdiction || '', + extended_statuses => $body->send_extended_statuses, + ); + + my $cobrand = $body->get_cobrand_handler; + $cobrand->call_hook(open311_config_updates => \%open311_conf) + if $cobrand; + + my $open311 = Open311->new(%open311_conf); + my $updates = Open311::GetServiceRequestUpdates->new( + system_user => $user, + current_open311 => $open311, + current_body => $body, + blank_updates_permitted => 1, + ); + + my $description = $updates->comment_text_for_request({}, $problem, 'confirmed', 'dummy', '', ''); + next unless $description; + + my $request = { + service_request_id => $problem->id, + update_id => 'auto-internal', + comment_time => DateTime->now, + status => 'open', + description => $description, + }; + $updates->process_update($request, $problem); + } + + # And now the reporter alert return if $c->stash->{no_reporter_alert}; + return if $c->cobrand->call_hook('suppress_reporter_alerts'); - my $problem = $c->stash->{report}; my $alert = $c->model('DB::Alert')->find_or_create( { user => $problem->user, alert_type => 'new_updates', @@ -1576,12 +1770,24 @@ sub generate_category_extra_json : Private { my $false = JSON->false; my @fields = map { - { - %$_, - required => $_->{required} eq "true" ? $true : $false, - variable => $_->{variable} eq "true" ? $true : $false, - order => int($_->{order}), + my %data = %$_; + + # Mobile app still looks in datatype_description + if (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) { + foreach my $opt (@{$_->{values}}) { + if ($opt->{disable}) { + my $message = $opt->{disable_message} || $_->{datatype_description}; + $data{datatype_description} = $message; + } + } } + + # Remove unneeded + delete $data{$_} for qw(datatype protected variable order disable_form); + delete $data{datatype_description} unless $data{datatype_description}; + + $data{required} = ($_->{required} || '') eq "true" ? $true : $false; + \%data; } @{ $c->stash->{category_extras}->{$c->stash->{category}} }; return \@fields; diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index cbedf7a01..41c42b8a1 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -4,6 +4,7 @@ use Moose; use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } +use utf8; use Path::Class; use List::Util 'first'; use Utils; @@ -105,6 +106,17 @@ sub process_user : Private { # Update form includes two username fields: #form_username_register and #form_username_sign_in $params{username} = (first { $_ } $c->get_param_list('username')) || ''; + my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously'); + if ($anon_button) { + my $anon_details = $c->cobrand->anonymous_account; + my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} }); + $user->name($anon_details->{name}); + $update->user($user); + $update->name($user->name); + $c->stash->{contributing_as_anonymous_user} = 1; + return 1; + } + # Extra block to use 'last' if ( $c->user_exists ) { { my $user = $c->user->obj; @@ -115,13 +127,16 @@ sub process_user : Private { } $user->name( Utils::trim_text( $params{name} ) ) if $params{name}; + $update->name($user->name); my $title = Utils::trim_text( $params{fms_extra_title} ); $user->title( $title ) if $title; $update->user( $user ); # Just in case, make sure the user will have a name if ($c->stash->{contributing_as_body} or $c->stash->{contributing_as_anonymous_user}) { - $user->name($user->from_body->name) unless $user->name; + my $name = $user->moderating_user_name; + $update->name($name); + $user->name($name) unless $user->name; } return 1; @@ -143,7 +158,7 @@ sub process_user : Private { oauth_update => { $update->get_inflated_columns } }; unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) { - $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.'); + $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.'); return 1; } my $user = $c->user->obj; @@ -155,6 +170,7 @@ sub process_user : Private { $update->user->name( Utils::trim_text( $params{name} ) ) if $params{name}; + $update->name($update->user->name); $update->user->title( Utils::trim_text( $params{fms_extra_title} ) ) if $params{fms_extra_title}; @@ -244,8 +260,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 if $c->cobrand->updates_disallowed($c->stash->{problem}); return $c->get_param('submit_update') || ''; } @@ -277,14 +292,21 @@ sub process_update : Private { $c->stash->{contributing_as_body} = $c->user_exists && $c->user->contributing_as('body', $c, $update->problem->bodies_str_ids); $c->stash->{contributing_as_anonymous_user} = $c->user_exists && $c->user->contributing_as('anonymous_user', $c, $update->problem->bodies_str_ids); + + # This is also done in process_user, but is needed here for anonymous() just below + my $anon_button = $c->cobrand->allow_anonymous_reports($update->problem->category) eq 'button' && $c->get_param('report_anonymously'); + if ($anon_button) { + $c->stash->{contributing_as_anonymous_user} = 1; + $c->stash->{contributing_as_body} = undef; + $c->stash->{contributing_as_another_user} = undef; + } + + if ($c->stash->{contributing_as_body}) { - $update->name($c->user->from_body->name); $update->anonymous(0); } elsif ($c->stash->{contributing_as_anonymous_user}) { - $update->name($c->user->from_body->name); $update->anonymous(1); } else { - $update->name($name); $update->anonymous($c->get_param('may_show_name') ? 0 : 1); } @@ -366,7 +388,7 @@ sub check_for_errors : Private { ); # if using social login then we don't care about name and email errors - $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in'); + $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0; if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; delete $field_errors{username}; @@ -394,6 +416,13 @@ sub check_for_errors : Private { #push @{ $c->stash->{errors} }, # _('There were problems with your update. Please see below.'); + if ( $c->cobrand->allow_anonymous_reports ) { + my $anon_details = $c->cobrand->anonymous_account; + my $update = $c->stash->{update}; + $update->user->email(undef) if $update->user->email eq $anon_details->{email}; + $update->name(undef) if $update->name && $update->name eq $anon_details->{name}; + } + return; } @@ -404,10 +433,8 @@ sub tokenize_user : Private { name => $update->user->name, password => $update->user->password, }; - $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ]) + if $c->get_param('oauth_need_email'); } =head2 save_update @@ -440,11 +467,7 @@ sub save_update : Private { $c->stash->{detach_to} = '/report/update/oauth_callback'; $c->stash->{detach_args} = [$token->token]; - if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/social/facebook_sign_in'); - } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/social/twitter_sign_in'); - } + $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in'); } if ( $c->cobrand->never_confirm_updates ) { @@ -508,7 +531,7 @@ sub redirect_or_confirm_creation : Private { return 1; } - # Superusers using 2FA can not log in by code + # People using 2FA can not log in by code $c->detach( '/page_error_403_access_denied', [] ) if $update->user->has_2fa; my $data = $c->stash->{token_data}; @@ -585,8 +608,20 @@ sub process_confirmation : Private { for (qw(name facebook_id twitter_id)) { $comment->user->$_( $data->{$_} ) if $data->{$_}; } + $comment->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id}; + $comment->user->extra({ + %{ $comment->user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; $comment->user->password( $data->{password}, 1 ) if $data->{password}; $comment->user->update; + # Make sure extra oauth state is restored, if applicable + foreach (qw/logout_redirect_uri change_password_uri/) { + if ($data->{$_}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{$_} = $data->{$_}; + } + } } if ($comment->user->email_verified) { @@ -636,6 +671,8 @@ sub signup_for_alerts : Private { $alert->disable(); } + $c->cobrand->call_hook(update_email_shortlisted_user => $update); + return 1; } diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index 49bdce379..97976ebe3 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -151,6 +151,7 @@ sub ward : Path : Args(2) { if @wards; $c->forward( 'check_canonical_url', [ $body ] ); $c->forward( 'stash_report_filter_status' ); + $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]); $c->forward( 'load_and_group_problems' ); if ($c->get_param('ajax')) { @@ -164,20 +165,44 @@ sub ward : Path : Args(2) { $c->stash->{stats} = $c->cobrand->get_report_stats(); + $c->forward('setup_categories_and_map'); + + # List of wards + if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) { + my $children = $c->stash->{body}->first_area_children; + unless ($children->{error}) { + foreach (values %$children) { + $_->{url} = $c->uri_for( $c->stash->{body_url} + . '/' . $c->cobrand->short_name( $_ ) + ); + } + $c->stash->{children} = $children; + } + } +} + +sub setup_categories_and_map :Private { + my ($self, $c) = @_; + my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, { - columns => [ 'id', 'category', 'extra' ], + columns => [ 'id', 'category', 'extra', 'body_id', 'send_method' ], distinct => 1, - order_by => [ 'category' ], - } )->all; + } )->all_sorted; + + $c->cobrand->call_hook('munge_reports_category_list', \@categories); + $c->stash->{filter_categories} = \@categories; $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) }; + $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups; my $pins = $c->stash->{pins} || []; + my $areas = [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ]; + $c->cobrand->call_hook(munge_reports_area_list => $areas); my %map_params = ( latitude => @$pins ? $pins->[0]{latitude} : 0, longitude => @$pins ? $pins->[0]{longitude} : 0, - area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ], + area => $areas, any_zoom => 1, ); FixMyStreet::Map::display_map( @@ -185,19 +210,6 @@ sub ward : Path : Args(2) { ); $c->cobrand->tweak_all_reports_map( $c ); - - # List of wards - if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) { - my $children = $c->stash->{body}->first_area_children; - unless ($children->{error}) { - foreach (values %$children) { - $_->{url} = $c->uri_for( $c->stash->{body_url} - . '/' . $c->cobrand->short_name( $_ ) - ); - } - $c->stash->{children} = $children; - } - } } sub rss_area : Path('/rss/area') : Args(1) { @@ -287,12 +299,12 @@ sub rss_ward : Path('/rss/reports') : Args(2) { if ($c->stash->{ward}) { # Problems sent to a council, restricted to a ward $c->stash->{type} = 'ward_problems'; - $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name, WARD => $c->stash->{ward}{name} }; + $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name, WARD => $c->stash->{ward}{name} }; $c->stash->{db_params} = [ $c->stash->{body}->id, $c->stash->{ward}->{id} ]; } else { # Problems sent to a council $c->stash->{type} = 'council_problems'; - $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name }; + $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name }; $c->stash->{db_params} = [ $c->stash->{body}->id ]; } @@ -391,9 +403,7 @@ sub ward_check : Private { $parent_id = $c->stash->{area}->{id}; } - my $qw = FixMyStreet::MapIt::call('area/children', [ $parent_id ], - type => $c->cobrand->area_types_children, - ); + my $qw = $c->cobrand->fetch_area_children($parent_id); my %names = map { $c->cobrand->short_name({ name => $_ }) => 1 } @wards; my @areas; foreach my $area (sort { $a->{name} cmp $b->{name} } values %$qw) { @@ -548,9 +558,51 @@ sub load_dashboard_data : Private { sub load_and_group_problems : Private { my ( $self, $c ) = @_; - $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]); + my $parameters = $c->forward('load_problems_parameters'); + my $body = $c->stash->{body}; # Might be undef my $page = $c->get_param('p') || 1; + + my $problems = $c->cobrand->problems; + my $where = $parameters->{where}; + my $filter = $parameters->{filter}; + + if ($where->{areas} || $body) { + $problems = $problems->to_body($body); + } + + $problems = $problems->search( + $where, + $filter + )->include_comment_counts->page( $page ); + + $c->stash->{pager} = $problems->pager; + + my ( %problems, @pins ); + while ( my $problem = $problems->next ) { + if ( !$body ) { + add_row( $c, $problem, 0, \%problems, \@pins ); + next; + } + # 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 ); + } + } + + $c->stash( + problems => \%problems, + pins => \@pins, + ); + + return 1; +} + +sub load_problems_parameters : Private { + my ($self, $c) = @_; + my $category = [ $c->get_param_list('filter_category', 1) ]; my $states = $c->stash->{filter_problem_states}; @@ -563,7 +615,7 @@ sub load_and_group_problems : Private { my $body = $c->stash->{body}; # Might be undef my $filter = { - order_by => $c->stash->{sort_order}, + order_by => [ $c->stash->{sort_order}, { -desc => 'me.id' } ], rows => $c->cobrand->reports_per_page, }; if ($c->user_exists && $body) { @@ -597,15 +649,10 @@ sub load_and_group_problems : Private { $where->{category} = $category; } - my $problems = $c->cobrand->problems; - if ($c->stash->{wards}) { $where->{areas} = [ map { { 'like', '%,' . $_->{id} . ',%' } } @{$c->stash->{wards}} ]; - $problems = $problems->to_body($body); - } elsif ($body) { - $problems = $problems->to_body($body); } if (my $bbox = $c->get_param('bbox')) { @@ -614,52 +661,21 @@ sub load_and_group_problems : Private { $where->{longitude} = { '>=', $min_lon, '<', $max_lon }; } - 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->cobrand->call_hook('munge_load_and_group_problems', $where, $filter); - 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 ) { - if ( !$body ) { - add_row( $c, $problem, 0, \%problems, \@pins ); - next; - } - # 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 ); - } - } - - $c->stash( - problems => \%problems, - pins => \@pins, - ); - - return 1; + return { + where => $where, + filter => $filter, + }; } - 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 ) { + if ( $c->user->is_superuser ) { $user_has_permission = 1; } else { my $body = $c->stash->{body}; @@ -702,8 +718,9 @@ sub stash_report_filter_status : Private { my @status = $c->get_param_list('status', 1); @status = ($c->stash->{page} eq 'my' ? 'all' : $c->cobrand->on_map_default_status) unless @status; - my %status = map { $_ => 1 } @status; + $c->cobrand->call_hook(hook_report_filter_status => \@status); + my %status = map { $_ => 1 } @status; my %filter_problem_states; my %filter_status; @@ -810,7 +827,12 @@ sub ajax : Private { my @pins = map { my $p = $_; # lat, lon, 'colour', ID, title, type/size, draggable - [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ] + my $parts = [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ]; + # Some reports may only be visible on a specific cobrand on this FMS site. + # If that's the case, include the base URL for the pin's cobrand here so + # the app can link to the right place. + push @$parts, $p->{base_url} if $p->{base_url}; + $parts; } @{$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 340c930c2..71dcf8e27 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -39,8 +39,11 @@ sub auto : Private { # decide which cobrand this request should use $c->setup_request(); + $c->forward('check_password_expiry'); $c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed'); + $c->forward('/offline/_stash_manifest_theme', [ $c->cobrand->moniker ]); + return 1; } @@ -122,7 +125,9 @@ sub page_error_410_gone : Private { sub page_error_403_access_denied : Private { my ( $self, $c, $error_msg ) = @_; - $c->detach('page_error', [ $error_msg || _("Sorry, you don't have permission to do that."), 403 ]); + $c->stash->{title} = _('Access denied'); + $error_msg ||= _("Sorry, you don't have permission to do that."); + $c->detach('page_error', [ $error_msg, 403 ]); } sub page_error_400_bad_request : Private { @@ -156,14 +161,30 @@ sub check_login_required : Private { }x; return if $c->request->path =~ $whitelist; - # Blacklisted URLs immediately 404 - # This is primarily to work around a Safari bug where the appcache - # URL is requested in an infinite loop if it returns a 302 redirect. - $c->detach('/page_error_404_not_found', []) if $c->request->path =~ /^offline/; - $c->detach( '/auth/redirect' ); } +sub check_password_expiry : Private { + my ($self, $c) = @_; + + return unless $c->user_exists; + + return if $c->action eq $c->controller('JS')->action_for('translation_strings'); + return if $c->controller eq $c->controller('Auth'); + + my $expiry = $c->cobrand->call_hook('password_expiry'); + return unless $expiry; + + my $last_change = $c->user->get_extra_metadata('last_password_change') || 0; + my $midnight = int(time()/86400)*86400; + my $expired = $last_change + $expiry < $midnight; + return unless $expired; + + my $uri = $c->uri_for('/auth/expired'); + $c->res->redirect( $uri ); + $c->detach; +} + =head2 end Attempt to render a view, if needed. diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm index 443e45b93..55b3088e7 100755 --- a/perllib/FixMyStreet/App/Controller/Rss.pm +++ b/perllib/FixMyStreet/App/Controller/Rss.pm @@ -186,6 +186,7 @@ sub generate : Private { $c->stash->{rss} = new XML::RSS( version => '2.0', encoding => 'UTF-8', + stylesheet => '/rss/xsl', encode_output => undef ); $c->stash->{rss}->add_module( @@ -222,8 +223,11 @@ sub query_main : Private { # FIXME Do this in a nicer way at some point in the future... my $query = 'select * from ' . $alert_type->item_table . ' where ' . ($alert_type->head_table ? $alert_type->head_table . '_id=? and ' : '') - . $alert_type->item_where . ' order by ' - . $alert_type->item_order; + . $alert_type->item_where . ' '; + if ($c->cobrand->can('problems_sql_restriction')) { + $query .= $c->cobrand->problems_sql_restriction($alert_type->item_table); + } + $query .= ' order by ' . $alert_type->item_order; my $rss_limit = FixMyStreet->config('RSS_LIMIT'); $query .= " limit $rss_limit" unless $c->stash->{type} =~ /^all/; @@ -298,9 +302,8 @@ sub add_row : Private { $item{description} .= encode_entities("\n<br>$address") if $address; } - my $recipient_name = $c->cobrand->contact_name; $item{description} .= encode_entities("\n<br><a href='$url'>" . - sprintf(_("Report on %s"), $recipient_name) . "</a>"); + sprintf(_("Report on %s"), $c->stash->{site_name}) . "</a>"); if ($row->{latitude} || $row->{longitude}) { $item{georss} = { point => "$row->{latitude} $row->{longitude}" }; @@ -328,6 +331,7 @@ sub add_parameters : Private { foreach ( keys %{ $c->stash->{title_params} } ) { $row->{$_} = $c->stash->{title_params}->{$_}; } + $row->{SITE_NAME} = $c->stash->{site_name}; (my $title = _($alert_type->head_title)) =~ s/\{\{(.*?)}}/$row->{$1}/g; (my $link = $alert_type->head_link) =~ s/\{\{(.*?)}}/$row->{$1}/g; @@ -377,6 +381,20 @@ sub redirect_lat_lon : Private { $c->res->redirect( "/rss/l/$lat,$lon" . $d_str . $state_qs ); } +sub xsl : Path { + my ($self, $c) = @_; + + my @include_path = @{ $c->cobrand->path_to_email_templates($c->stash->{lang_code}) }; + my $vars = { + %{ $c->stash }, + additional_template_paths => \@include_path, + }; + my $body = $c->view('Email')->render($c, 'xsl.xsl', $vars); + + $c->response->header('Content-Type' => 'text/xml; charset=utf-8'); + $c->response->body($body); +} + =head1 AUTHOR Matthew Somerville diff --git a/perllib/FixMyStreet/App/Controller/Status.pm b/perllib/FixMyStreet/App/Controller/Status.pm index 57c8f362e..e56a7930a 100755 --- a/perllib/FixMyStreet/App/Controller/Status.pm +++ b/perllib/FixMyStreet/App/Controller/Status.pm @@ -31,7 +31,7 @@ sub index : Path : Args(0) { # superusers. It doesn't have anything sensitive $c->stash->{admin_type} = 'super'; # Fetch summary stats from admin front page - $c->forward('/admin/index'); + $c->forward('/admin/stats/gather'); # Fetch git version $c->forward('/admin/config_page'); diff --git a/perllib/FixMyStreet/App/Controller/Test.pm b/perllib/FixMyStreet/App/Controller/Test.pm new file mode 100644 index 000000000..5ec4bebf3 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Test.pm @@ -0,0 +1,60 @@ +package FixMyStreet::App::Controller::Test; +use Moose; +use namespace::autoclean; + +use File::Basename; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Test - Catalyst Controller + +=head1 DESCRIPTION + +Test-helping Catalyst Controller. + +=head1 METHODS + +=over 4 + +=item auto + +Makes sure this controller is only available when run in test. + +=cut + +sub auto : Private { + my ($self, $c) = @_; + $c->detach( '/page_error_404_not_found' ) unless FixMyStreet->test_mode; + return 1; +} + +=item setup + +Sets up a particular browser test. + +=cut + +sub setup : Path('/_test/setup') : Args(1) { + my ( $self, $c, $test ) = @_; + if ($test eq 'regression-duplicate-hide') { + my $problem = FixMyStreet::DB->resultset("Problem")->find(1); + $problem->update({ category => 'Skips' }); + $c->response->body("OK"); + } +} + +sub teardown : Path('/_test/teardown') : Args(1) { + my ( $self, $c, $test ) = @_; + if ($test eq 'regression-duplicate-hide') { + my $problem = FixMyStreet::DB->resultset("Problem")->find(1); + $problem->update({ category => 'Potholes' }); + $c->response->body("OK"); + } +} + +__PACKAGE__->meta->make_immutable; + +1; + diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm index 659d763de..c4e601a85 100644 --- a/perllib/FixMyStreet/App/Controller/Tokens.pm +++ b/perllib/FixMyStreet/App/Controller/Tokens.pm @@ -185,9 +185,7 @@ sub alert_to_reporter : Path('/R') { my $problem = $c->model('DB::Problem')->find( { id => $problem_id } ) || $c->detach('token_error'); - $c->detach('token_too_old') if $auth_token->created < DateTime->now->subtract( months => 1 ); - - $c->flash->{alert_to_reporter} = 1; + $c->flash->{alert_to_reporter} = $problem->id; my $report_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url; $c->res->redirect($report_uri); } |