diff options
Diffstat (limited to 'perllib/FixMyStreet')
66 files changed, 2764 insertions, 1438 deletions
diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index a0477ca40..e47336b7c 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -81,6 +81,19 @@ __PACKAGE__->config( user_model => 'DB::User', }, }, + access_token => { + use_session => 0, + credential => { + class => 'AccessToken', + token_field => 'extra', + # This means the token has to be 18 characters long (as generated by AuthToken) + token_lookup => { like => "%access_token,T18:TOKEN,%" }, + }, + store => { + class => 'DBIx::Class', + user_model => 'DB::User', + }, + }, }, ); @@ -212,6 +225,8 @@ sub setup_request { Memcached::set_namespace( FixMyStreet->config('FMS_DB_NAME') . ":" ); FixMyStreet::Map::set_map_class( $cobrand->map_type || $c->get_param('map_override') ); + # All pages need this, either loading it or prefetching it + $c->stash->{map_js} = FixMyStreet::Map::map_javascript(); unless ( FixMyStreet->config('MAPIT_URL') ) { my $port = $c->req->uri->port; diff --git a/perllib/FixMyStreet/App/Controller/About.pm b/perllib/FixMyStreet/App/Controller/About.pm index 233da25d3..48a5dfffd 100755 --- a/perllib/FixMyStreet/App/Controller/About.pm +++ b/perllib/FixMyStreet/App/Controller/About.pm @@ -23,6 +23,7 @@ sub page : Path("/about") : Args(1) { my $template = $c->forward('find_template'); $c->detach('/page_error_404_not_found', []) unless $template; $c->stash->{template} = $template; + $c->cobrand->call_hook('about_hook'); } sub index : Path("/about") : Args(0) { diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index ed40f4565..a5c29fce3 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -14,6 +14,7 @@ use List::MoreUtils 'uniq'; use mySociety::ArrayUtils; use FixMyStreet::SendReport; +use FixMyStreet::SMS; =head1 NAME @@ -72,7 +73,7 @@ sub index : Path : Args(0) { return $c->cobrand->admin(); } - $c->forward('stats_by_state'); + $c->forward('/admin/stats/state'); my @unsent = $c->cobrand->problems->search( { state => [ FixMyStreet::DB::Result::Problem::open_states() ], @@ -182,39 +183,6 @@ sub timeline : Path( 'timeline' ) : Args(0) { return 1; } -sub questionnaire : Path('stats/questionnaire') : Args(0) { - my ( $self, $c ) = @_; - - my $questionnaires = $c->model('DB::Questionnaire')->search( - { whenanswered => { '!=', undef } }, - { group_by => [ 'ever_reported' ], - select => [ 'ever_reported', { count => 'me.id' } ], - as => [ qw/reported questionnaire_count/ ] } - ); - - my %questionnaire_counts = map { - ( defined $_->get_column( 'reported' ) ? $_->get_column( 'reported' ) : -1 ) - => $_->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} = \%questionnaire_counts; - - $c->stash->{state_changes_count} = $c->model('DB::Questionnaire')->search( - { whenanswered => \'is not null' } - )->count; - $c->stash->{state_changes} = $c->model('DB::Questionnaire')->search( - { whenanswered => \'is not null' }, - { - group_by => [ 'old_state', 'new_state' ], - columns => [ 'old_state', 'new_state', { c => { count => 'id' } } ], - }, - ); - - return 1; -} - sub bodies : Path('bodies') : Args(0) { my ( $self, $c ) = @_; @@ -503,7 +471,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->search({ state => { '!=' => 'deleted' } }); + $c->stash->{live_contacts} = $contacts->not_deleted; $c->stash->{any_not_confirmed} = $contacts->search({ state => 'unconfirmed' })->count; if ( $c->get_param('text') && $c->get_param('text') eq '1' ) { @@ -553,7 +521,7 @@ sub fetch_translations : Private { $c->stash->{translations} = $translations; } -sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { +sub lookup_body : Private { my ( $self, $c, $body_id ) = @_; $c->stash->{body_id} = $body_id; @@ -561,7 +529,14 @@ sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { $c->detach( '/page_error_404_not_found', [] ) unless $body; $c->stash->{body} = $body; - +} + +sub body : Chained('/') : PathPart('admin/body') : CaptureArgs(1) { + my ( $self, $c, $body_id ) = @_; + + $c->forward('lookup_body'); + my $body = $c->stash->{body}; + if ($body->body_areas->first) { my $example_postcode = mySociety::MaPit::call('area/example_postcode', $body->body_areas->first->area_id); if ($example_postcode && ! ref $example_postcode) { @@ -600,9 +575,12 @@ sub edit_body : Chained('body') : PathPart('') : Args(0) { $c->set_param('posted', ''); $c->forward('fetch_translations'); - $c->forward('fetch_contacts'); + # don't set this last as fetch_contacts might over-ride it + # to display email addresses as text $c->stash->{template} = 'admin/body.html'; + $c->forward('fetch_contacts'); + return 1; } @@ -671,6 +649,10 @@ sub reports : Path('reports') { 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. @@ -679,10 +661,19 @@ sub reports : Path('reports') { }, { columns => [ 'id' ] } )->all; @user_ids = map { $_->id } @user_ids; - if (is_valid_email($search)) { + 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), @@ -698,7 +689,7 @@ sub reports : Path('reports') { } else { $query->{'-or'} = [ 'me.id' => $search_n, - 'me.user_id' => { -in => \@user_ids }, + '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 }, @@ -719,10 +710,14 @@ sub reports : Path('reports') { $c->stash->{problems} = [ $problems->all ]; $c->stash->{problems_pager} = $problems->pager; - if (is_valid_email($search)) { + 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), @@ -734,7 +729,7 @@ sub reports : Path('reports') { $query = [ 'me.id' => $search_n, 'problem.id' => $search_n, - 'me.user_id' => { -in => \@user_ids }, + 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] }, 'me.name' => { ilike => $like_search }, text => { ilike => $like_search }, 'me.cobrand_data' => { ilike => $like_search }, @@ -772,6 +767,19 @@ sub reports : Path('reports') { } +sub update_user : Private { + my ($self, $c, $object) = @_; + my $parsed = FixMyStreet::SMS->parse_username($c->get_param('username')); + if ($parsed->{email} || ($parsed->{phone} && $parsed->{may_be_mobile})) { + my $user = $c->model('DB::User')->find_or_create({ $parsed->{type} => $parsed->{username} }); + if ($user->id && $user->id != $object->user->id) { + $object->user( $user ); + return 1; + } + } + return 0; +} + sub report_edit : Path('report_edit') : Args(1) { my ( $self, $c, $id ) = @_; @@ -827,7 +835,7 @@ sub report_edit : Path('report_edit') : Args(1) { return if $done; } - $c->forward('check_email_for_abuse', [ $problem->user->email ] ); + $c->forward('check_username_for_abuse', [ $problem->user ] ); $c->stash->{updates} = [ $c->model('DB::Comment') @@ -875,14 +883,8 @@ sub report_edit : Path('report_edit') : Args(1) { } $problem->set_inflated_columns(\%columns); - $c->forward( '/admin/report_edit_category', [ $problem ] ); - - my $email = lc $c->get_param('email'); - if ( $email ne $problem->user->email ) { - my $user = $c->model('DB::User')->find_or_create({ email => $email }); - $user->insert unless $user->in_storage; - $problem->user( $user ); - } + $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); @@ -903,6 +905,27 @@ sub report_edit : Path('report_edit') : Args(1) { if ( $problem->state ne $old_state ) { $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] ); + + my $name = _('an administrator'); + my $extra = { is_superuser => 1 }; + if ($c->user->from_body) { + $name = $c->user->from_body->name; + delete $extra->{is_superuser}; + $extra->{is_body_user} = $c->user->from_body->id; + } + 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' ] ); @@ -924,7 +947,7 @@ Handles changing a problem's category and the complexity that comes with it. =cut sub report_edit_category : Private { - my ($self, $c, $problem) = @_; + my ($self, $c, $problem, $no_comment) = @_; if ((my $category = $c->get_param('category')) ne $problem->category) { my $category_old = $problem->category; @@ -937,31 +960,33 @@ sub report_edit_category : Private { $problem->whensent(undef); } # If the send methods of the old/new contacts differ we need to resend the report - my @old_contacts = grep { $_->category eq $category_old } @{$c->stash->{contacts}}; my @new_send_methods = uniq map { ( $_->body->can_be_devolved && $_->send_method ) ? - $_->send_method : $_->body->send_method; + $_->send_method : $_->body->send_method + ? $_->body->send_method + : $c->cobrand->_fallback_body_sender()->{method}; } @contacts; - my @old_send_methods = map { - ( $_->body->can_be_devolved && $_->send_method ) ? - $_->send_method : $_->body->send_method; - } @old_contacts; - if ( scalar @{ mySociety::ArrayUtils::symmetric_diff(\@old_send_methods, \@new_send_methods) } ) { - $c->log->debug("Report changed, resending"); + my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); + if (grep !$old_send_methods{$_}, @new_send_methods) { $problem->whensent(undef); } $problem->bodies_str(join( ',', @new_body_ids )); - $problem->add_to_comments({ - text => '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*', - 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, - }); + 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, + }); + } } } @@ -1074,19 +1099,52 @@ sub template_edit : Path('templates') : Args(2) { } } @live_contacts; $c->stash->{contacts} = \@all_contacts; - if ($c->req->method eq 'POST') { + # 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->auto_response( $c->get_param('auto_response') ? 1 : 0 ); - $template->update_or_insert; - my @live_contact_ids = map { $_->id } @live_contacts; - my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; + $template->auto_response( $c->get_param('auto_response') && $template->state ? 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 ], + 'me.state' => $template->state, + }; + 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} = { + 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; @@ -1098,7 +1156,7 @@ sub template_edit : Path('templates') : Args(2) { } $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) ); - } + } } $c->stash->{response_template} = $template; @@ -1138,28 +1196,15 @@ sub users: Path('users') : Args(0) { { -or => [ email => { ilike => $isearch }, + phone => { ilike => $isearch }, name => { ilike => $isearch }, from_body => $search_n, ] } ); my @users = $users->all; - my %email2user = map { $_->email => $_ } @users; $c->stash->{users} = [ @users ]; - - if ( $c->user->is_superuser ) { - my $emails = $c->model('DB::Abuse')->search( - { email => { ilike => $isearch } } - ); - foreach my $email ($emails->all) { - # Slight abuse of the boolean flagged value - if ($email2user{$email->email}) { - $email2user{$email->email}->flagged( 2 ); - } else { - push @{$c->stash->{users}}, { email => $email->email, flagged => 2 }; - } - } - } + $c->forward('add_flags', [ { email => { ilike => $isearch } } ]); } else { $c->forward('/auth/get_csrf_token'); @@ -1171,9 +1216,7 @@ sub users: Path('users') : Args(0) { { order_by => 'name' } ); my @users = $users->all; - my %email2user = map { $_->email => $_ } @users; $c->stash->{users} = \@users; - } return 1; @@ -1196,7 +1239,7 @@ sub update_edit : Path('update_edit') : Args(1) { return 1; } - $c->forward('check_email_for_abuse', [ $update->user->email ] ); + $c->forward('check_username_for_abuse', [ $update->user ] ); if ( $c->get_param('banuser') ) { $c->forward('ban_user'); @@ -1220,9 +1263,7 @@ sub update_edit : Path('update_edit') : Args(1) { # $update->name can be null which makes ne unhappy my $name = $update->name || ''; - my $email = lc $c->get_param('email'); if ( $c->get_param('name') ne $name - || $email ne $update->user->email || $c->get_param('anonymous') ne $update->anonymous || $c->get_param('text') ne $update->text ) { $edited = 1; @@ -1242,11 +1283,7 @@ sub update_edit : Path('update_edit') : Args(1) { $update->anonymous( $c->get_param('anonymous') ); $update->state( $new_state ); - if ( $email ne $update->user->email ) { - my $user = $c->model('DB::User')->find_or_create({ email => $email }); - $user->insert unless $user->in_storage; - $update->user($user); - } + $edited = 1 if $c->forward('update_user', [ $update ]); if ( $new_state eq 'confirmed' and $old_state eq 'unconfirmed' ) { $update->confirmed( \'current_timestamp' ); @@ -1286,6 +1323,18 @@ sub update_edit : Path('update_edit') : Args(1) { return 1; } +sub phone_check : Private { + my ($self, $c, $phone) = @_; + my $parsed = FixMyStreet::SMS->parse_username($phone); + if ($parsed->{phone} && $parsed->{may_be_mobile}) { + return $parsed->{username}; + } elsif ($parsed->{phone}) { + $c->stash->{field_errors}->{phone} = _('Please enter a mobile number'); + } else { + $c->stash->{field_errors}->{phone} = _('Please check your phone number is correct'); + } +} + sub user_add : Path('user_edit') : Args(0) { my ( $self, $c ) = @_; @@ -1298,24 +1347,47 @@ sub user_add : Path('user_edit') : Args(0) { $c->forward('/auth/check_csrf_token'); $c->stash->{field_errors} = {}; - unless ($c->get_param('email')) { + my $email = lc $c->get_param('email'); + my $phone = $c->get_param('phone'); + my $email_v = $c->get_param('email_verified'); + my $phone_v = $c->get_param('phone_verified'); + + unless ($email || $phone) { + $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); + } + if (!$email_v && !$phone_v) { + $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); + } + if ($email && !is_valid_email($email)) { $c->stash->{field_errors}->{email} = _('Please enter a valid email'); } unless ($c->get_param('name')) { $c->stash->{field_errors}->{name} = _('Please enter a name'); } + + if ($phone_v) { + my $parsed_phone = $c->forward('phone_check', [ $phone ]); + $phone = $parsed_phone if $parsed_phone; + } + + my $existing_email = $email_v && $c->model('DB::User')->find( { email => $email } ); + my $existing_phone = $phone_v && $c->model('DB::User')->find( { phone => $phone } ); + if ($existing_email || $existing_phone) { + $c->stash->{field_errors}->{username} = _('User already exists'); + } + return if %{$c->stash->{field_errors}}; - my $user = $c->model('DB::User')->find_or_create( { + my $user = $c->model('DB::User')->create( { name => $c->get_param('name'), - email => lc $c->get_param('email'), - phone => $c->get_param('phone') || undef, + email => $email ? $email : undef, + email_verified => $email && $email_v ? 1 : 0, + phone => $phone || undef, + phone_verified => $phone && $phone_v ? 1 : 0, from_body => $c->get_param('body') || undef, flagged => $c->get_param('flagged') || 0, # Only superusers can create superusers is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0, - }, { - key => 'users_email_key' } ); $c->stash->{user} = $user; $c->forward('user_cobrand_extra_fields'); @@ -1340,6 +1412,7 @@ sub user_edit : Path('user_edit') : Args(1) { } $c->stash->{user} = $user; + $c->forward( 'check_username_for_abuse', [ $user ] ); if ( $user->from_body && $c->user->has_permission_to('user_manage_permissions', $user->from_body->id) ) { $c->stash->{available_permissions} = $c->cobrand->available_permissions; @@ -1353,25 +1426,76 @@ sub user_edit : Path('user_edit') : Args(1) { '<p><em>' . $c->flash->{status_message} . '</em></p>'; } - if ( $c->get_param('submit') ) { + if ( $c->get_param('submit') and $c->get_param('unban') ) { + $c->forward('/auth/check_csrf_token'); + $c->forward('unban_user', [ $user ]); + } elsif ( $c->get_param('submit') ) { $c->forward('/auth/check_csrf_token'); my $edited = 0; + my $name = $c->get_param('name'); my $email = lc $c->get_param('email'); - if ( $user->email ne $email || - $user->name ne $c->get_param('name') || - ($user->phone || "") ne $c->get_param('phone') || + my $phone = $c->get_param('phone'); + my $email_v = $c->get_param('email_verified') || 0; + my $phone_v = $c->get_param('phone_verified') || 0; + + $c->stash->{field_errors} = {}; + + unless ($email || $phone) { + $c->stash->{field_errors}->{username} = _('Please enter a valid email or phone number'); + } + if (!$email_v && !$phone_v) { + $c->stash->{field_errors}->{username} = _('Please verify at least one of email/phone'); + } + if ($email && !is_valid_email($email)) { + $c->stash->{field_errors}->{email} = _('Please enter a valid email'); + } + + if ($phone_v) { + my $parsed_phone = $c->forward('phone_check', [ $phone ]); + $phone = $parsed_phone if $parsed_phone; + } + + unless ($name) { + $c->stash->{field_errors}->{name} = _('Please enter a name'); + } + + my $email_params = { email => $email, email_verified => 1, id => { '!=', $user->id } }; + my $phone_params = { phone => $phone, phone_verified => 1, id => { '!=', $user->id } }; + my $existing_email = $email_v && $c->model('DB::User')->search($email_params)->first; + my $existing_phone = $phone_v && $c->model('DB::User')->search($phone_params)->first; + my $existing_user = $existing_email || $existing_phone; + my $existing_email_cobrand = $email_v && $c->cobrand->users->search($email_params)->first; + my $existing_phone_cobrand = $phone_v && $c->cobrand->users->search($phone_params)->first; + my $existing_user_cobrand = $existing_email_cobrand || $existing_phone_cobrand; + if ($existing_phone_cobrand && $existing_email_cobrand && $existing_email_cobrand->id != $existing_phone_cobrand->id) { + $c->stash->{field_errors}->{username} = _('User already exists'); + } + + return if %{$c->stash->{field_errors}}; + + if ( ($user->email || "") ne $email || + $user->name ne $name || + ($user->phone || "") ne $phone || ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) || (!$user->from_body && $c->get_param('body')) ) { $edited = 1; } - $user->name( $c->get_param('name') ); - my $original_email = $user->email; - $user->email( $email ); - $user->phone( $c->get_param('phone') ) if $c->get_param('phone'); + if ($existing_user_cobrand) { + $existing_user->adopt($user); + $c->forward( 'log_edit', [ $id, 'user', 'merge' ] ); + return $c->res->redirect( $c->uri_for( 'user_edit', $existing_user->id ) ); + } + + $user->email($email) if !$existing_email; + $user->phone($phone) if !$existing_phone; + $user->email_verified( $email_v ); + $user->phone_verified( $phone_v ); + $user->name( $name ); + $user->flagged( $c->get_param('flagged') || 0 ); # Only superusers can grant superuser status $user->is_superuser( ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0 ); @@ -1450,8 +1574,6 @@ sub user_edit : Path('user_edit') : Args(1) { } } - $c->stash->{field_errors} = {}; - # Update the categories this user operates in if ( $user->from_body ) { $c->stash->{body} = $user->from_body; @@ -1462,33 +1584,12 @@ sub user_edit : Path('user_edit') : Args(1) { $user->set_extra_metadata('categories', \@new_contact_ids); } - unless ($user->email) { - $c->stash->{field_errors}->{email} = _('Please enter a valid email'); - } - unless ($user->name) { - $c->stash->{field_errors}->{name} = _('Please enter a name'); - } - return if %{$c->stash->{field_errors}}; - - my $existing_user = $c->model('DB::User')->search({ email => $user->email, id => { '!=', $user->id } })->first; - my $existing_user_cobrand = $c->cobrand->users->search({ email => $user->email, id => { '!=', $user->id } })->first; - if ($existing_user_cobrand) { - $existing_user->adopt($user); - $c->forward( 'log_edit', [ $id, 'user', 'merge' ] ); - $c->res->redirect( $c->uri_for( 'user_edit', $existing_user->id ) ); - } else { - if ($existing_user) { - # Tried to change email to an existing one lacking permission - # so make sure it's switched back - $user->email($original_email); - } - $user->update; - if ($edited) { - $c->forward( 'log_edit', [ $id, 'user', 'edit' ] ); - } - $c->flash->{status_message} = _("Updated!"); - $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); + $user->update; + if ($edited) { + $c->forward( 'log_edit', [ $id, 'user', 'edit' ] ); } + $c->flash->{status_message} = _("Updated!"); + return $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); } if ( $user->from_body ) { @@ -1519,148 +1620,40 @@ sub user_cobrand_extra_fields : Private { } } -sub flagged : Path('flagged') : Args(0) { - my ( $self, $c ) = @_; - - my $problems = $c->cobrand->problems->search( { flagged => 1 } ); - - # pass in as array ref as using same template as search_reports - # which has to use an array ref for sql quoting reasons - $c->stash->{problems} = [ $problems->all ]; +sub add_flags : Private { + my ( $self, $c, $search ) = @_; - my $users = $c->cobrand->users->search( { flagged => 1 } ); - my @users = $users->all; - my %email2user = map { $_->email => $_ } @users; - $c->stash->{users} = [ @users ]; + return unless $c->user->is_superuser; - my @abuser_emails = $c->model('DB::Abuse')->all() - if $c->user->is_superuser; + my $users = $c->stash->{users}; + my %email2user = map { $_->email => $_ } grep { $_->email } @$users; + my %phone2user = map { $_->phone => $_ } grep { $_->phone } @$users; + my %username2user = (%email2user, %phone2user); + my $usernames = $c->model('DB::Abuse')->search($search); - foreach my $email (@abuser_emails) { + foreach my $username (map { $_->email } $usernames->all) { # Slight abuse of the boolean flagged value - if ($email2user{$email->email}) { - $email2user{$email->email}->flagged( 2 ); + if ($username2user{$username}) { + $username2user{$username}->flagged( 2 ); } else { - push @{$c->stash->{users}}, { email => $email->email, flagged => 2 }; + push @{$c->stash->{users}}, { email => $username, flagged => 2 }; } } - - return 1; -} - -sub stats_by_state : Path('stats/state') : Args(0) { - my ( $self, $c ) = @_; - - my $problems = $c->cobrand->problems->summary_count; - - my %prob_counts = - map { $_->state => $_->get_column('state_count') } $problems->all; - - %prob_counts = - map { $_ => $prob_counts{$_} || 0 } - ( FixMyStreet::DB::Result::Problem->all_states() ); - $c->stash->{problems} = \%prob_counts; - $c->stash->{total_problems_live} += $prob_counts{$_} ? $prob_counts{$_} : 0 - for ( FixMyStreet::DB::Result::Problem->visible_states() ); - $c->stash->{total_problems_users} = $c->cobrand->problems->unique_users; - - my $comments = $c->cobrand->updates->summary_count; - - my %comment_counts = - map { $_->state => $_->get_column('state_count') } $comments->all; - - $c->stash->{comments} = \%comment_counts; } -sub stats_fix_rate : Path('stats/fix-rate') : Args(0) { - my ( $self, $c ) = @_; - - $c->stash->{categories} = $c->cobrand->problems->categories_summary(); -} - -sub stats : Path('stats') : Args(0) { +sub flagged : Path('flagged') : Args(0) { my ( $self, $c ) = @_; - my $selected_body; - if ( $c->user->is_superuser ) { - $c->forward('fetch_all_bodies'); - $selected_body = $c->get_param('body'); - } else { - $selected_body = $c->user->from_body->id; - } - - if ( $c->cobrand->moniker eq 'zurich' ) { - return $c->cobrand->admin_stats(); - } - - if ( $c->get_param('getcounts') ) { - - my ( $start_date, $end_date, @errors ); - my $parser = DateTime::Format::Strptime->new( pattern => '%d/%m/%Y' ); - - $start_date = $parser-> parse_datetime ( $c->get_param('start_date') ); - - push @errors, _('Invalid start date') unless defined $start_date; - - $end_date = $parser-> parse_datetime ( $c->get_param('end_date') ) ; - - push @errors, _('Invalid end date') unless defined $end_date; - - $c->stash->{errors} = \@errors; - $c->stash->{start_date} = $start_date; - $c->stash->{end_date} = $end_date; - - $c->stash->{unconfirmed} = $c->get_param('unconfirmed') eq 'on' ? 1 : 0; - - return 1 if @errors; - - my $bymonth = $c->get_param('bymonth'); - $c->stash->{bymonth} = $bymonth; - - $c->stash->{selected_body} = $selected_body; - - my $field = 'confirmed'; - - $field = 'created' if $c->get_param('unconfirmed'); - - my $one_day = DateTime::Duration->new( days => 1 ); - - - my %select = ( - select => [ 'state', { 'count' => 'me.id' } ], - as => [qw/state count/], - group_by => [ 'state' ], - order_by => [ 'state' ], - ); - - if ( $c->get_param('bymonth') ) { - %select = ( - select => [ - { extract => \"year from $field", -as => 'c_year' }, - { extract => \"month from $field", -as => 'c_month' }, - { 'count' => 'me.id' } - ], - as => [qw/c_year c_month count/], - group_by => [qw/c_year c_month/], - order_by => [qw/c_year c_month/], - ); - } + my $problems = $c->cobrand->problems->search( { flagged => 1 } ); - my $p = $c->cobrand->problems->to_body($selected_body)->search( - { - -AND => [ - $field => { '>=', $start_date}, - $field => { '<=', $end_date + $one_day }, - ], - }, - \%select, - ); + # pass in as array ref as using same template as search_reports + # which has to use an array ref for sql quoting reasons + $c->stash->{problems} = [ $problems->all ]; - # in case the total_report count is 0 - $c->stash->{show_count} = 1; - $c->stash->{states} = $p; - } + my @users = $c->cobrand->users->search( { flagged => 1 } )->all; + $c->stash->{users} = [ @users ]; + $c->forward('add_flags', [ {} ]); return 1; } @@ -1727,47 +1720,82 @@ sub log_edit : Private { =head2 ban_user -Add the email address in the email param of the request object to -the abuse table if they are not already in there and sets status_message -accordingly +Add the user's email address/phone number to the abuse table if they are not +already in there and sets status_message accordingly. =cut sub ban_user : Private { my ( $self, $c ) = @_; - my $email = lc $c->get_param('email'); - - return unless $email; - - my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $email }); + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } + return unless $user; - if ( $abuse->in_storage ) { - $c->stash->{status_message} = _('Email already in abuse list'); - } else { - $abuse->insert; - $c->stash->{status_message} = _('Email added to abuse list'); + if ($user->email_verified && $user->email) { + my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->email }); + if ( $abuse->in_storage ) { + $c->stash->{status_message} = _('User already in abuse list'); + } else { + $abuse->insert; + $c->stash->{status_message} = _('User added to abuse list'); + } + $c->stash->{username_in_abuse} = 1; + } + if ($user->phone_verified && $user->phone) { + my $abuse = $c->model('DB::Abuse')->find_or_new({ email => $user->phone }); + if ( $abuse->in_storage ) { + $c->stash->{status_message} = _('User already in abuse list'); + } else { + $abuse->insert; + $c->stash->{status_message} = _('User added to abuse list'); + } + $c->stash->{username_in_abuse} = 1; } + return 1; +} - $c->stash->{email_in_abuse} = 1; +sub unban_user : Private { + my ( $self, $c, $user ) = @_; - return 1; + my @username; + if ($user->email_verified && $user->email) { + push @username, $user->email; + } + if ($user->phone_verified && $user->phone) { + push @username, $user->phone; + } + if (@username) { + my $abuse = $c->model('DB::Abuse')->search({ email => \@username }); + if ( $abuse ) { + $abuse->delete; + $c->stash->{status_message} = _('user removed from abuse list'); + } else { + $c->stash->{status_message} = _('user not in abuse list'); + } + $c->stash->{username_in_abuse} = 0; + } } =head2 flag_user -Sets the flag on a user with the given email +Sets the flag on a user =cut sub flag_user : Private { my ( $self, $c ) = @_; - my $email = lc $c->get_param('email'); - - return unless $email; - - my $user = $c->cobrand->users->find({ email => $email }); + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } if ( !$user ) { $c->stash->{status_message} = _('Could not find user'); @@ -1784,18 +1812,19 @@ sub flag_user : Private { =head2 remove_user_flag -Remove the flag on a user with the given email +Remove the flag on a user =cut sub remove_user_flag : Private { my ( $self, $c ) = @_; - my $email = lc $c->get_param('email'); - - return unless $email; - - my $user = $c->cobrand->users->find({ email => $email }); + my $user; + if ($c->stash->{problem}) { + $user = $c->stash->{problem}->user; + } elsif ($c->stash->{update}) { + $user = $c->stash->{update}->user; + } if ( !$user ) { $c->stash->{status_message} = _('Could not find user'); @@ -1809,22 +1838,20 @@ sub remove_user_flag : Private { } -=head2 check_email_for_abuse +=head2 check_username_for_abuse - $c->forward('check_email_for_abuse', [ $email ] ); + $c->forward('check_username_for_abuse', [ $user ] ); -Checks if $email is in the abuse table and sets email_in_abuse accordingly +Checks if $user is in the abuse table and sets username_in_abuse accordingly. =cut -sub check_email_for_abuse : Private { - my ( $self, $c, $email ) =@_; - - my $is_abuse = $c->model('DB::Abuse')->find({ email => $email }); +sub check_username_for_abuse : Private { + my ( $self, $c, $user ) = @_; - $c->stash->{email_in_abuse} = 1 if $is_abuse; + my $is_abuse = $c->model('DB::Abuse')->find({ email => [ $user->phone, $user->email ] }); - return 1; + $c->stash->{username_in_abuse} = 1 if $is_abuse; } =head2 rotate_photo diff --git a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm new file mode 100644 index 000000000..2860b3531 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm @@ -0,0 +1,75 @@ +package FixMyStreet::App::Controller::Admin::Stats; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + return $c->cobrand->admin_stats() if $c->cobrand->moniker eq 'zurich'; +} + +sub state : Local : Args(0) { + my ( $self, $c ) = @_; + + my $problems = $c->cobrand->problems->summary_count; + + my %prob_counts = + map { $_->state => $_->get_column('state_count') } $problems->all; + + %prob_counts = + map { $_ => $prob_counts{$_} || 0 } + ( FixMyStreet::DB::Result::Problem->all_states() ); + $c->stash->{problems} = \%prob_counts; + $c->stash->{total_problems_live} += $prob_counts{$_} ? $prob_counts{$_} : 0 + for ( FixMyStreet::DB::Result::Problem->visible_states() ); + $c->stash->{total_problems_users} = $c->cobrand->problems->unique_users; + + my $comments = $c->cobrand->updates->summary_count; + + my %comment_counts = + map { $_->state => $_->get_column('state_count') } $comments->all; + + $c->stash->{comments} = \%comment_counts; +} + +sub fix_rate : Path('fix-rate') : Args(0) { + my ( $self, $c ) = @_; + + $c->stash->{categories} = $c->cobrand->problems->categories_summary(); +} + +sub questionnaire : Local : Args(0) { + my ( $self, $c ) = @_; + + my $questionnaires = $c->model('DB::Questionnaire')->search( + { whenanswered => { '!=', undef } }, + { group_by => [ 'ever_reported' ], + select => [ 'ever_reported', { count => 'me.id' } ], + as => [ qw/reported questionnaire_count/ ] } + ); + + my %questionnaire_counts = map { + ( defined $_->get_column( 'reported' ) ? $_->get_column( 'reported' ) : -1 ) + => $_->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} = \%questionnaire_counts; + + $c->stash->{state_changes_count} = $c->model('DB::Questionnaire')->search( + { whenanswered => \'is not null' } + )->count; + $c->stash->{state_changes} = $c->model('DB::Questionnaire')->search( + { whenanswered => \'is not null' }, + { + group_by => [ 'old_state', 'new_state' ], + columns => [ 'old_state', 'new_state', { c => { count => 'id' } } ], + }, + ); + + return 1; +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index b872084ff..da17cbd56 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -8,6 +8,7 @@ use FixMyStreet::Map; use Encode; use JSON::MaybeXS; use Utils; +use Try::Tiny; =head1 NAME @@ -32,19 +33,24 @@ If no search redirect back to the homepage. sub index : Path : Args(0) { my ( $self, $c ) = @_; - # handle old coord systems - $c->forward('redirect_en_or_xy_to_latlon'); - - # Check if we have a partial report - my $partial_report = $c->forward('load_partial'); + if ($c->get_param('ajax')) { + $c->detach('ajax'); + } # Check if the user is searching for a report by ID if ( $c->get_param('pc') && $c->get_param('pc') =~ $c->cobrand->lookup_by_ref_regex ) { $c->go('lookup_by_ref', [ $1 ]); } + # handle old coord systems + $c->forward('redirect_en_or_xy_to_latlon'); + + # Check if we have a partial report + my $partial_report = $c->forward('load_partial'); + # Try to create a location for whatever we have - my $ret = $c->forward('/location/determine_location_from_coords') + my $ret = $c->forward('/location/determine_location_from_bbox') + || $c->forward('/location/determine_location_from_coords') || $c->forward('/location/determine_location_from_pc'); unless ($ret) { return $c->res->redirect('/') unless $c->get_param('pc') || $partial_report; @@ -54,18 +60,22 @@ sub index : Path : Args(0) { # Check to see if the spot is covered by a area - if not show an error. return unless $c->forward('check_location_is_acceptable', []); - # If we have a partial - redirect to /report/new so that it can be - # completed. - if ($partial_report) { - my $new_uri = $c->uri_for( - '/report/new', - { - partial => $c->stash->{partial_token}->token, - latitude => $c->stash->{latitude}, - longitude => $c->stash->{longitude}, - pc => $c->stash->{pc}, - } - ); + # Redirect to /report/new in two cases: + # - if we have a partial report, so that it can be completed. + # - if the cobrand doesn't show anything on /around (e.g. a private + # reporting site) + if ($partial_report || $c->cobrand->call_hook("skip_around_page")) { + my $params = { + latitude => $c->stash->{latitude}, + longitude => $c->stash->{longitude}, + pc => $c->stash->{pc} + }; + if ($partial_report) { + $params->{partial} = $c->stash->{partial_token}->token; + } elsif ($c->get_param("category")) { + $params->{category} = $c->get_param("category"); + } + my $new_uri = $c->uri_for('/report/new', $params); return $c->res->redirect($new_uri); } @@ -169,7 +179,11 @@ sub display_location : Private { my $latitude = $c->stash->{latitude}; my $longitude = $c->stash->{longitude}; - $c->forward('map_features', [ { latitude => $latitude, longitude => $longitude } ] ); + if (my $bbox = $c->stash->{bbox}) { + $c->forward('map_features', [ { bbox => $bbox } ]); + } else { + $c->forward('map_features', [ { latitude => $latitude, longitude => $longitude } ]); + } FixMyStreet::Map::display_map( $c, @@ -244,16 +258,11 @@ sub map_features : Private { $c->forward( '/reports/stash_report_filter_status' ); $c->forward( '/reports/stash_report_sort', [ 'created-desc' ]); - # Deal with pin hiding/age - my $all_pins = $c->get_param('all_pins') ? 1 : undef; - $c->stash->{all_pins} = $all_pins; - my $interval = $all_pins ? undef : $c->cobrand->on_map_default_max_pin_age; - return if $c->get_param('js'); # JS will request the same (or more) data client side - my ( $on_map_all, $on_map_list, $nearby, $distance ) = + my ( $on_map, $nearby, $distance ) = FixMyStreet::Map::map_features( - $c, interval => $interval, %$extra, + $c, %$extra, categories => [ keys %{$c->stash->{filter_category}} ], states => $c->stash->{filter_problem_states}, order => $c->stash->{sort_order}, @@ -265,16 +274,16 @@ sub map_features : Private { # Here we might have a DB::Problem or a DB::Result::Nearby, we always want the problem. my $p = (ref $_ eq 'FixMyStreet::DB::Result::Nearby') ? $_->problem : $_; $p->pin_data($c, 'around'); - } @$on_map_all, @$nearby; + } @$on_map, @$nearby; } $c->stash->{pins} = \@pins; - $c->stash->{on_map} = $on_map_list; + $c->stash->{on_map} = $on_map; $c->stash->{around_map} = $nearby; $c->stash->{distance} = $distance; } -=head2 /ajax +=head2 ajax Handle the ajax calls that the map makes when it is dragged. The info returned is used to update the pins on the map and the text descriptions on the side of @@ -282,11 +291,11 @@ the map. =cut -sub ajax : Path('/ajax') { +sub ajax : Private { my ( $self, $c ) = @_; - my $bbox = $c->get_param('bbox'); - unless ($bbox) { + my $ret = $c->forward('/location/determine_location_from_bbox'); + unless ($ret) { $c->res->status(404); $c->res->body(''); return; @@ -295,7 +304,7 @@ sub ajax : Path('/ajax') { my %valid_categories = map { $_ => 1 } $c->get_param_list('filter_category', 1); $c->stash->{filter_category} = \%valid_categories; - $c->forward('map_features', [ { bbox => $bbox } ]); + $c->forward('map_features', [ { bbox => $c->stash->{bbox} } ]); $c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]); } @@ -338,10 +347,8 @@ sub _geocode : Private { } else { if ( ref($suggestions) eq 'ARRAY' ) { foreach (@$suggestions) { - my $address = $_->{address}; - $address = decode_utf8($address) if !utf8::is_utf8($address); - push @addresses, $address; - push @locations, { address => $address, lat => $_->{latitude}, long => $_->{longitude} }; + push @addresses, $_->{address}; + push @locations, { address => $_->{address}, lat => $_->{latitude}, long => $_->{longitude} }; } $response = { suggestions => \@addresses, locations => \@locations }; } else { @@ -366,13 +373,17 @@ sub lookup_by_ref : Private { external_id => $ref ]); - if ( $problems->count == 0) { - $c->detach( '/page_error_404_not_found', [] ); - } elsif ( $problems->count == 1 ) { - $c->res->redirect( $c->uri_for( '/report', $problems->first->id ) ); - } else { + my $count = try { + $problems->count; + } catch { + 0; + }; + + if ($count > 1) { $c->stash->{ref} = $ref; $c->stash->{matching_reports} = [ $problems->all ]; + } elsif ($count == 1) { + $c->res->redirect( $c->uri_for( '/report', $problems->first->id ) ); } } diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 825066026..455022e03 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -5,12 +5,10 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } use Email::Valid; -use Net::Domain::TLD; use Digest::HMAC_SHA1 qw(hmac_sha1); use JSON::MaybeXS; use MIME::Base64; -use Net::Facebook::Oauth2; -use Net::Twitter::Lite::WithAPIv1_1; +use FixMyStreet::SMS; =head1 NAME @@ -38,19 +36,19 @@ sub general : Path : Args(0) { # all done unless we have a form posted to us return unless $c->req->method eq 'POST'; - my $clicked_email = $c->get_param('email_sign_in'); - my $data_address = $c->get_param('email'); + my $clicked_sign_in_by_code = $c->get_param('sign_in_by_code'); + my $data_username = $c->get_param('username'); my $data_password = $c->get_param('password_sign_in'); my $data_email = $c->get_param('name') || $c->get_param('password_register'); # decide which action to take - $c->detach('email_sign_in') if $clicked_email || ($data_email && !$data_password); - if (!$data_address && !$data_password && !$data_email) { - $c->detach('facebook_sign_in') if $c->get_param('facebook_sign_in'); - $c->detach('twitter_sign_in') if $c->get_param('twitter_sign_in'); + $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'); } - $c->forward( 'sign_in' ) + $c->forward( 'sign_in', [ $data_username ] ) && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); } @@ -60,6 +58,13 @@ sub general_test : Path('_test_') : Args(0) { $c->stash->{template} = 'auth/token.html'; } +sub authenticate : Private { + my ($self, $c, $type, $username, $password) = @_; + return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password }); + return 1 if FixMyStreet->config('SMS_AUTHENTICATION') && $type eq 'phone' && $c->authenticate({ phone => $username, phone_verified => 1, password => $password }); + return 0; +} + =head2 sign_in Allow the user to sign in with a username and a password. @@ -67,21 +72,18 @@ Allow the user to sign in with a username and a password. =cut sub sign_in : Private { - my ( $self, $c, $email ) = @_; + my ( $self, $c, $username ) = @_; - $email ||= $c->get_param('email') || ''; - $email = lc $email; + $username ||= ''; my $password = $c->get_param('password_sign_in') || ''; my $remember_me = $c->get_param('remember_me') || 0; # Sign out just in case $c->logout(); - if ( $email - && $password - && $c->authenticate( { email => $email, password => $password } ) ) - { + my $parsed = FixMyStreet::SMS->parse_username($username); + if ($parsed->{username} && $password && $c->forward('authenticate', [ $parsed->{type}, $parsed->{username}, $password ])) { # unless user asked to be remembered limit the session to browser $c->set_session_cookie_expire(0) unless $remember_me; @@ -94,25 +96,40 @@ sub sign_in : Private { $c->stash( sign_in_error => 1, - email => $email, + username => $username, remember_me => $remember_me, ); return; } -=head2 email_sign_in +=head2 code_sign_in -Email the user the details they need to sign in. Don't check for an account - if -there isn't one we can create it when they come back with a token (which -contains the email address). +Either email the user a link to sign in, or send an SMS token to do so. + +Don't check for an account - if there isn't one we can create it when +they come back with a token (which contains the email/phone). =cut -sub email_sign_in : Private { +sub code_sign_in : Private { my ( $self, $c ) = @_; + my $username = $c->stash->{username} = $c->get_param('username') || ''; + + my $parsed = FixMyStreet::SMS->parse_username($username); + + if ($parsed->{type} eq 'phone' && FixMyStreet->config('SMS_AUTHENTICATION')) { + $c->forward('phone/sign_in', [ $parsed ]); + } else { + $c->forward('email_sign_in', [ $parsed->{username} ]); + } +} + +sub email_sign_in : Private { + my ( $self, $c, $email ) = @_; + # check that the email is valid - otherwise flag an error - my $raw_email = lc( $c->get_param('email') || '' ); + my $raw_email = lc( $email || '' ); my $email_checker = Email::Valid->new( -mxcheck => 1, @@ -122,9 +139,7 @@ sub email_sign_in : Private { my $good_email = $email_checker->address($raw_email); if ( !$good_email ) { - $c->stash->{email} = $raw_email; - $c->stash->{email_error} = - $raw_email ? $email_checker->details : 'missing'; + $c->stash->{username_error} = $raw_email ? $email_checker->details : 'missing_email'; return; } @@ -133,7 +148,7 @@ sub email_sign_in : Private { # NB this uses the same template as a successful sign in to stop # enumeration of valid email addresses. if ( FixMyStreet->config('SIGNUPS_DISABLED') - && !$c->model('DB::User')->search({ email => $good_email })->count + && !$c->model('DB::User')->find({ email => $good_email }) && !$c->stash->{current_user} # don't break the change email flow ) { $c->stash->{template} = 'auth/token.html'; @@ -156,7 +171,7 @@ sub email_sign_in : Private { $token_data->{twitter_id} = $c->session->{oauth}{twitter_id} if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; if ($c->stash->{current_user}) { - $token_data->{old_email} = $c->stash->{current_user}->email; + $token_data->{old_user_id} = $c->stash->{current_user}->id; $token_data->{r} = 'auth/change_email/success'; } @@ -171,6 +186,20 @@ sub email_sign_in : Private { $c->stash->{template} = 'auth/token.html'; } +sub get_token : Private { + my ( $self, $c, $token, $scope ) = @_; + + $c->stash->{token_not_found} = 1, return unless $token; + + my $token_obj = $c->model('DB::Token')->find({ scope => $scope, token => $token }); + + $c->stash->{token_not_found} = 1, return unless $token_obj; + $c->stash->{token_not_found} = 1, return if $token_obj->created < DateTime->now->subtract( days => 1 ); + + my $data = $token_obj->data; + return $data; +} + =head2 token Handle the 'email_sign_in' tokens. Find the account for the email address @@ -181,53 +210,43 @@ Handle the 'email_sign_in' tokens. Find the account for the email address sub token : Path('/M') : Args(1) { my ( $self, $c, $url_token ) = @_; - # retrieve the token or return - my $token_obj = $url_token - ? $c->model('DB::Token')->find( { - scope => 'email_sign_in', token => $url_token - } ) - : undef; + my $data = $c->forward('get_token', [ $url_token, 'email_sign_in' ]) || return; - if ( !$token_obj ) { - $c->stash->{token_not_found} = 1; - return; - } - - if ( $token_obj->created < DateTime->now->subtract( days => 1 ) ) { - $c->stash->{token_not_found} = 1; - return; - } + $c->stash->{token_not_found} = 1, return + if $data->{old_user_id} && (!$c->user_exists || $c->user->id ne $data->{old_user_id}); - # find or create the user related to the token. - my $data = $token_obj->data; + my $type = $data->{login_type} || 'email'; + $c->detach( '/auth/process_login', [ $data, $type ] ); +} - if ($data->{old_email} && (!$c->user_exists || $c->user->email ne $data->{old_email})) { - $c->stash->{token_not_found} = 1; - return; - } +sub process_login : Private { + my ( $self, $c, $data, $type ) = @_; # sign out in case we are another user $c->logout(); - my $user = $c->model('DB::User')->find_or_new({ email => $data->{email} }); + my $user = $c->model('DB::User')->find_or_new({ $type => $data->{$type} }); + my $ver = "${type}_verified"; # Bail out if this is a new user and SIGNUPS_DISABLED is set $c->detach( '/page_error_403_access_denied', [] ) - if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_email}; + if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id}; - if ($data->{old_email}) { - # Were logged in as old_email, want to switch to email ($user) + if ($data->{old_user_id}) { + # Were logged in as old_user_id, want to switch to $user if ($user->in_storage) { - my $old_user = $c->model('DB::User')->find({ email => $data->{old_email} }); + my $old_user = $c->model('DB::User')->find({ id => $data->{old_user_id} }); if ($old_user) { $old_user->adopt($user); $user = $old_user; - $user->email($data->{email}); + $user->$type($data->{$type}); + $user->$ver(1); } } else { - # Updating to a new (to the db) email address, easier! - $user = $c->model('DB::User')->find({ email => $data->{old_email} }); - $user->email($data->{email}); + # Updating to a new (to the db) email address/phone number, easier! + $user = $c->model('DB::User')->find({ id => $data->{old_user_id} }); + $user->$type($data->{$type}); + $user->$ver(1); } } @@ -236,193 +255,12 @@ sub token : Path('/M') : Args(1) { $user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id}; $user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id}; $user->update_or_insert; - $c->authenticate( { email => $user->email }, 'no_password' ); + $c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' ); # send the user to their page $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] ); } -=head2 facebook_sign_in - -Starts the Facebook authentication sequence. - -=cut - -sub fb : Private { - my ($self, $c) = @_; - Net::Facebook::Oauth2->new( - application_id => $c->config->{FACEBOOK_APP_ID}, - application_secret => $c->config->{FACEBOOK_APP_SECRET}, - callback => $c->uri_for('/auth/Facebook'), - ); -} - -sub facebook_sign_in : Private { - my ( $self, $c ) = @_; - - $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); - - my $fb = $c->forward('/auth/fb'); - my $url = $fb->get_authorization_url(scope => ['email']); - - my %oauth; - $oauth{return_url} = $c->get_param('r'); - $oauth{detach_to} = $c->stash->{detach_to}; - $oauth{detach_args} = $c->stash->{detach_args}; - $c->session->{oauth} = \%oauth; - $c->res->redirect($url); -} - -=head2 facebook_callback - -Handles the Facebook callback request and completes the authentication sequence. - -=cut - -sub facebook_callback: Path('/auth/Facebook') : Args(0) { - my ( $self, $c ) = @_; - - $c->detach('oauth_failure') if $c->get_param('error_code'); - - my $fb = $c->forward('/auth/fb'); - my $access_token; - eval { - $access_token = $fb->get_access_token(code => $c->get_param('code')); - }; - if ($@) { - (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; - $c->detach('/page_error_500_internal_error', [ $message ]); - } - - # save this token in session - $c->session->{oauth}{token} = $access_token; - - my $info = $fb->get('https://graph.facebook.com/me?fields=name,email')->as_hash(); - my $email = lc ($info->{email} || ""); - $c->forward('oauth_success', [ 'facebook', $info->{id}, $info->{name}, $email ]); -} - -=head2 twitter_sign_in - -Starts the Twitter authentication sequence. - -=cut - -sub tw : Private { - my ($self, $c) = @_; - Net::Twitter::Lite::WithAPIv1_1->new( - ssl => 1, - consumer_key => $c->config->{TWITTER_KEY}, - consumer_secret => $c->config->{TWITTER_SECRET}, - ); -} - -sub twitter_sign_in : Private { - my ( $self, $c ) = @_; - - $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); - - my $twitter = $c->forward('/auth/tw'); - my $url = $twitter->get_authentication_url(callback => $c->uri_for('/auth/Twitter')); - - my %oauth; - $oauth{return_url} = $c->get_param('r'); - $oauth{detach_to} = $c->stash->{detach_to}; - $oauth{detach_args} = $c->stash->{detach_args}; - $oauth{token} = $twitter->request_token; - $oauth{token_secret} = $twitter->request_token_secret; - $c->session->{oauth} = \%oauth; - $c->res->redirect($url); -} - -=head2 twitter_callback - -Handles the Twitter callback request and completes the authentication sequence. - -=cut - -sub twitter_callback: Path('/auth/Twitter') : Args(0) { - my ( $self, $c ) = @_; - - my $request_token = $c->req->param('oauth_token'); - my $verifier = $c->req->param('oauth_verifier'); - my $oauth = $c->session->{oauth}; - - $c->detach('oauth_failure') if $c->get_param('denied') || $request_token ne $oauth->{token}; - - my $twitter = $c->forward('/auth/tw'); - $twitter->request_token($oauth->{token}); - $twitter->request_token_secret($oauth->{token_secret}); - - eval { - # request_access_token no longer returns UID or name - $twitter->request_access_token(verifier => $verifier); - }; - if ($@) { - (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; - $c->detach('/page_error_500_internal_error', [ $message ]); - } - - my $info = $twitter->verify_credentials(); - $c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]); -} - -sub oauth_failure : Private { - my ( $self, $c ) = @_; - - $c->stash->{oauth_failure} = 1; - if ($c->session->{oauth}{detach_to}) { - $c->detach($c->session->{oauth}{detach_to}, $c->session->{oauth}{detach_args}); - } else { - $c->stash->{template} = 'auth/general.html'; - $c->detach; - } -} - -sub oauth_success : Private { - my ($self, $c, $type, $uid, $name, $email) = @_; - - my $user; - if ($email) { - # Only Facebook gets 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 - $user = $c->model('DB::User')->find_or_new( { email => $email } ); - $user->facebook_id($uid); - $user->name($name); - $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 ($user) { - # Matching ID in our database - $user->name($name); - $user->update; - } else { - # No matching ID, store ID for use later - $c->session->{oauth}{$type . '_id'} = $uid; - $c->stash->{oauth_need_email} = 1; - } - } - - # If we've got here with a full user, log in - if ($user) { - $c->authenticate( { email => $user->email }, 'no_password' ); - $c->stash->{login_success} = 1; - } - - if ($c->session->{oauth}{detach_to}) { - $c->detach($c->session->{oauth}{detach_to}, $c->session->{oauth}{detach_args}); - } elsif ($c->stash->{oauth_need_email}) { - $c->stash->{template} = 'auth/general.html'; - } else { - $c->detach( 'redirect_on_signin', [ $c->session->{oauth}{return_url} ] ); - } -} - =head2 redirect_on_signin Used after signing in to take the person back to where they were. @@ -478,69 +316,6 @@ sub redirect : Private { } -=head2 change_password - -Let the user change their password. - -=cut - -sub change_password : Local { - my ( $self, $c ) = @_; - - $c->detach( 'redirect' ) unless $c->user; - - $c->forward('get_csrf_token'); - - # If not a post then no submission - return unless $c->req->method eq 'POST'; - - $c->forward('check_csrf_token'); - - # get the passwords - my $new = $c->get_param('new_password') // ''; - my $confirm = $c->get_param('confirm') // ''; - - # check for errors - my $password_error = - !$new && !$confirm ? 'missing' - : $new ne $confirm ? 'mismatch' - : ''; - - if ($password_error) { - $c->stash->{password_error} = $password_error; - $c->stash->{new_password} = $new; - $c->stash->{confirm} = $confirm; - return; - } - - # we should have a usable password - save it to the user - $c->user->obj->update( { password => $new } ); - $c->stash->{password_changed} = 1; - -} - -=head2 change_email - -Let the user change their email. - -=cut - -sub change_email : Local { - my ( $self, $c ) = @_; - - $c->detach( 'redirect' ) unless $c->user; - - $c->forward('get_csrf_token'); - - # If not a post then no submission - return unless $c->req->method eq 'POST'; - - $c->forward('check_csrf_token'); - $c->stash->{current_user} = $c->user; - $c->stash->{email_template} = 'change_email.txt'; - $c->forward('email_sign_in'); -} - sub get_csrf_token : Private { my ( $self, $c ) = @_; @@ -588,7 +363,7 @@ sub ajax_sign_in : Path('ajax/sign_in') { my ( $self, $c ) = @_; my $return = {}; - if ( $c->forward( 'sign_in' ) ) { + if ( $c->forward( 'sign_in', [ $c->get_param('email') ] ) ) { $return->{name} = $c->user->name; } else { $return->{error} = 1; @@ -644,6 +419,8 @@ Mainly intended for testing but might also be useful for ajax calls. sub check_auth : Local { my ( $self, $c ) = @_; + $c->authenticate(undef, 'access_token') unless $c->user; + # choose the response my ( $body, $code ) # = $c->user diff --git a/perllib/FixMyStreet/App/Controller/Auth/Phone.pm b/perllib/FixMyStreet/App/Controller/Auth/Phone.pm new file mode 100644 index 000000000..8387b9d64 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Auth/Phone.pm @@ -0,0 +1,108 @@ +package FixMyStreet::App::Controller::Auth::Phone; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use FixMyStreet::SMS; + +=head1 NAME + +FixMyStreet::App::Controller::Auth::Phone - Catalyst Controller + +=head1 DESCRIPTION + +Controller for phone SMS based authentication + +=head1 METHODS + +=head2 code + +Handle the submission of a code sent by text to a mobile number. + +=cut + +sub code : Path('') { + my ( $self, $c, $scope, $success_action ) = @_; + $c->stash->{template} = 'auth/smsform.html'; + $scope ||= 'phone_sign_in'; + $success_action ||= '/auth/process_login'; + + my $token = $c->stash->{token} = $c->get_param('token'); + my $code = $c->get_param('code') || ''; + + my $data = $c->stash->{token_data} = $c->forward('/auth/get_token', [ $token, $scope ]) || return; + + $c->stash->{incorrect_code} = 1, return if $data->{code} ne $code; + + $c->detach( $success_action, [ $data, 'phone' ] ); +} + +=head2 sign_in + +When signing in with a mobile phone number, we are sent here. +This sends a text to that number with a confirmation code, +and sets up the token/etc to deal with the response. + +=cut + +sub sign_in : Private { + my ( $self, $c, $parsed ) = @_; + + unless ($parsed->{phone}) { + $c->stash->{username_error} = 'other_phone'; + return; + } + + unless ($parsed->{may_be_mobile}) { + $c->stash->{username_error} = 'nonmobile'; + return; + } + + (my $number = $parsed->{phone}->format) =~ s/\s+//g; + + if ( FixMyStreet->config('SIGNUPS_DISABLED') + && !$c->model('DB::User')->find({ phone => $number }) + && !$c->stash->{current_user} # don't break the change phone flow + ) { + $c->stash->{template} = 'auth/token.html'; + return; + } + + my $user_params = {}; + $user_params->{password} = $c->get_param('password_register') + if $c->get_param('password_register'); + my $user = $c->model('DB::User')->new( $user_params ); + + my $token_data = { + phone => $number, + r => $c->get_param('r'), + name => $c->get_param('name'), + password => $user->password, + }; + if ($c->stash->{current_user}) { + $token_data->{old_user_id} = $c->stash->{current_user}->id; + $token_data->{r} = 'auth/change_phone/success'; + } + + $c->forward('send_token', [ $token_data, 'phone_sign_in', $number ]); +} + +sub send_token : Private { + my ( $self, $c, $token_data, $token_scope, $to ) = @_; + + my $result = FixMyStreet::SMS->send_token($token_data, $token_scope, $to); + if ($result->{error}) { + $c->log->debug("Failure sending text containing code *$result->{random}*"); + $c->stash->{sms_error} = $result->{error}; + $c->stash->{username_error} = 'sms_failed'; + return; + } + $c->stash->{token} = $result->{token}; + $c->log->debug("Sending text containing code *$result->{random}*"); + $c->stash->{template} = 'auth/smsform.html'; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm new file mode 100644 index 000000000..5e6fe6266 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm @@ -0,0 +1,173 @@ +package FixMyStreet::App::Controller::Auth::Profile; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use mySociety::AuthToken; + +=head1 NAME + +FixMyStreet::App::Controller::Auth::Profile - Catalyst Controller + +=head1 DESCRIPTION + +Controller for all the authentication profile related pages - adding/ changing/ +verifying email, phone, password. + +=head1 METHODS + +=cut + +sub auto { + my ( $self, $c ) = @_; + + $c->detach( '/auth/redirect' ) unless $c->user; + + return 1; +} + +=head2 change_password + +Let the user change their password. + +=cut + +sub change_password : Path('/auth/change_password') { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'auth/change_password.html'; + + $c->forward('/auth/get_csrf_token'); + + # If not a post then no submission + return unless $c->req->method eq 'POST'; + + $c->forward('/auth/check_csrf_token'); + + # get the passwords + my $new = $c->get_param('new_password') // ''; + my $confirm = $c->get_param('confirm') // ''; + + # check for errors + my $password_error = + !$new && !$confirm ? 'missing' + : $new ne $confirm ? 'mismatch' + : ''; + + if ($password_error) { + $c->stash->{password_error} = $password_error; + $c->stash->{new_password} = $new; + $c->stash->{confirm} = $confirm; + return; + } + + # we should have a usable password - save it to the user + $c->user->obj->update( { password => $new } ); + $c->stash->{password_changed} = 1; + +} + +=head2 change_email + +Let the user change their email. + +=cut + +sub change_email : Path('/auth/change_email') { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'auth/change_email.html'; + + $c->forward('/auth/get_csrf_token'); + + # If not a post then no submission + return unless $c->req->method eq 'POST'; + + $c->forward('/auth/check_csrf_token'); + $c->stash->{current_user} = $c->user; + $c->stash->{email_template} = 'change_email.txt'; + $c->forward('/auth/email_sign_in', [ $c->get_param('email') ]); +} + +sub change_phone : Path('/auth/change_phone') { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'auth/change_phone.html'; + + $c->forward('/auth/get_csrf_token'); + + # If not a post then no submission + return unless $c->req->method eq 'POST'; + + $c->forward('/auth/check_csrf_token'); + $c->stash->{current_user} = $c->user; + + my $phone = $c->stash->{username} = $c->get_param('username') || ''; + my $parsed = FixMyStreet::SMS->parse_username($phone); + + # Allow removal of phone number, if we have verified email + if (!$phone && !$c->stash->{verifying} && $c->user->email_verified) { + $c->user->update({ phone => undef, phone_verified => 0 }); + $c->flash->{flash_message} = _('You have successfully removed your phone number.'); + $c->res->redirect('/my'); + $c->detach; + } + + $c->stash->{username_error} = 'missing_phone', return unless $phone; + $c->stash->{username_error} = 'other_phone', return unless $parsed->{phone}; + + # If we've not used a mobile and we're not specifically verifying, + # and phone isn't our only verified way of logging in, + # then allow change of number (for e.g. landline). + if (!FixMyStreet->config('SMS_AUTHENTICATION') || (!$parsed->{may_be_mobile} && !$c->stash->{verifying} && $c->user->email_verified)) { + $c->user->update({ phone => $phone, phone_verified => 0 }); + $c->flash->{flash_message} = _('You have successfully added your phone number.'); + $c->res->redirect('/my'); + $c->detach; + } + + $c->forward('/auth/phone/sign_in', [ $parsed ]); +} + +sub verify_item : Path('/auth/verify') : Args(1) { + my ( $self, $c, $type ) = @_; + $c->stash->{verifying} = 1; + $c->detach("change_$type"); +} + +sub change_email_success : Path('/auth/change_email/success') { + my ( $self, $c ) = @_; + $c->flash->{flash_message} = _('You have successfully confirmed your email address.'); + $c->res->redirect('/my'); +} + +sub change_phone_success : Path('/auth/change_phone/success') { + my ( $self, $c ) = @_; + $c->flash->{flash_message} = _('You have successfully verified your phone number.'); + $c->res->redirect('/my'); +} + +sub generate_token : Path('/auth/generate_token') { + my ($self, $c) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) + unless $c->user and ( $c->user->is_superuser or $c->user->from_body ); + + $c->stash->{template} = 'auth/generate_token.html'; + $c->forward('/auth/get_csrf_token'); + + if ($c->req->method eq 'POST') { + $c->forward('/auth/check_csrf_token'); + my $token = mySociety::AuthToken::random_token(); + $c->user->set_extra_metadata('access_token', $token); + $c->user->update(); + $c->stash->{token_generated} = 1; + } + + $c->stash->{existing_token} = $c->user->get_extra_metadata('access_token'); +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm new file mode 100644 index 000000000..097cac984 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Auth/Social.pm @@ -0,0 +1,203 @@ +package FixMyStreet::App::Controller::Auth::Social; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use Net::Facebook::Oauth2; +use Net::Twitter::Lite::WithAPIv1_1; + +=head1 NAME + +FixMyStreet::App::Controller::Auth::Social - Catalyst Controller + +=head1 DESCRIPTION + +Controller for the Facebook/Twitter authentication. + +=head1 METHODS + +=head2 facebook_sign_in + +Starts the Facebook authentication sequence. + +=cut + +sub fb : Private { + my ($self, $c) = @_; + Net::Facebook::Oauth2->new( + application_id => $c->config->{FACEBOOK_APP_ID}, + application_secret => $c->config->{FACEBOOK_APP_SECRET}, + callback => $c->uri_for('/auth/Facebook'), + ); +} + +sub facebook_sign_in : Private { + my ( $self, $c ) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); + + my $fb = $c->forward('fb'); + my $url = $fb->get_authorization_url(scope => ['email']); + + my %oauth; + $oauth{return_url} = $c->get_param('r'); + $oauth{detach_to} = $c->stash->{detach_to}; + $oauth{detach_args} = $c->stash->{detach_args}; + $c->session->{oauth} = \%oauth; + $c->res->redirect($url); +} + +=head2 facebook_callback + +Handles the Facebook callback request and completes the authentication sequence. + +=cut + +sub facebook_callback: Path('/auth/Facebook') : Args(0) { + my ( $self, $c ) = @_; + + $c->detach('oauth_failure') if $c->get_param('error_code'); + + my $fb = $c->forward('fb'); + my $access_token; + eval { + $access_token = $fb->get_access_token(code => $c->get_param('code')); + }; + if ($@) { + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); + } + + # save this token in session + $c->session->{oauth}{token} = $access_token; + + my $info = $fb->get('https://graph.facebook.com/me?fields=name,email')->as_hash(); + my $email = lc ($info->{email} || ""); + $c->forward('oauth_success', [ 'facebook', $info->{id}, $info->{name}, $email ]); +} + +=head2 twitter_sign_in + +Starts the Twitter authentication sequence. + +=cut + +sub tw : Private { + my ($self, $c) = @_; + Net::Twitter::Lite::WithAPIv1_1->new( + ssl => 1, + consumer_key => $c->config->{TWITTER_KEY}, + consumer_secret => $c->config->{TWITTER_SECRET}, + ); +} + +sub twitter_sign_in : Private { + my ( $self, $c ) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); + + my $twitter = $c->forward('tw'); + my $url = $twitter->get_authentication_url(callback => $c->uri_for('/auth/Twitter')); + + my %oauth; + $oauth{return_url} = $c->get_param('r'); + $oauth{detach_to} = $c->stash->{detach_to}; + $oauth{detach_args} = $c->stash->{detach_args}; + $oauth{token} = $twitter->request_token; + $oauth{token_secret} = $twitter->request_token_secret; + $c->session->{oauth} = \%oauth; + $c->res->redirect($url); +} + +=head2 twitter_callback + +Handles the Twitter callback request and completes the authentication sequence. + +=cut + +sub twitter_callback: Path('/auth/Twitter') : Args(0) { + my ( $self, $c ) = @_; + + my $request_token = $c->req->param('oauth_token'); + my $verifier = $c->req->param('oauth_verifier'); + my $oauth = $c->session->{oauth}; + + $c->detach('oauth_failure') if $c->get_param('denied') || $request_token ne $oauth->{token}; + + my $twitter = $c->forward('tw'); + $twitter->request_token($oauth->{token}); + $twitter->request_token_secret($oauth->{token_secret}); + + eval { + # request_access_token no longer returns UID or name + $twitter->request_access_token(verifier => $verifier); + }; + if ($@) { + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); + } + + my $info = $twitter->verify_credentials(); + $c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]); +} + +sub oauth_failure : Private { + my ( $self, $c ) = @_; + + $c->stash->{oauth_failure} = 1; + if ($c->session->{oauth}{detach_to}) { + $c->detach($c->session->{oauth}{detach_to}, $c->session->{oauth}{detach_args}); + } else { + $c->stash->{template} = 'auth/general.html'; + $c->detach; + } +} + +sub oauth_success : Private { + my ($self, $c, $type, $uid, $name, $email) = @_; + + my $user; + if ($email) { + # Only Facebook gets 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 + $user = $c->model('DB::User')->find_or_new( { email => $email } ); + $user->facebook_id($uid); + $user->name($name); + $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 ($user) { + # Matching ID in our database + $user->name($name); + $user->update; + } else { + # No matching ID, store ID for use later + $c->session->{oauth}{$type . '_id'} = $uid; + $c->stash->{oauth_need_email} = 1; + } + } + + # If we've got here with a full user, log in + if ($user) { + $c->authenticate( { email => $user->email, email_verified => 1 }, 'no_password' ); + $c->stash->{login_success} = 1; + } + + if ($c->session->{oauth}{detach_to}) { + $c->detach($c->session->{oauth}{detach_to}, $c->session->{oauth}{detach_args}); + } elsif ($c->stash->{oauth_need_email}) { + $c->stash->{template} = 'auth/general.html'; + } else { + $c->detach( '/auth/redirect_on_signin', [ $c->session->{oauth}{return_url} ] ); + } +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index f961660c0..926e941f6 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -3,8 +3,10 @@ use Moose; use namespace::autoclean; use DateTime; -use File::Slurp; use JSON::MaybeXS; +use Path::Tiny; +use Text::CSV; +use Time::Piece; BEGIN { extends 'Catalyst::Controller'; } @@ -20,43 +22,21 @@ Catalyst Controller. =cut +sub auto : Private { + my ($self, $c) = @_; + $c->stash->{filter_states} = $c->cobrand->state_groups_inspect; + return 1; +} + sub example : Local : Args(0) { my ( $self, $c ) = @_; $c->stash->{template} = 'dashboard/index.html'; - $c->stash->{filter_states} = $c->cobrand->state_groups_inspect; - - $c->stash->{children} = {}; - for my $i (1..3) { - $c->stash->{children}{$i} = { id => $i, name => "Ward $i" }; - } - - # TODO Set up manual version of what the below would do - #$c->forward( '/report/new/setup_categories_and_bodies' ); - - # See if we've had anything from the dropdowns - perhaps vary results if so - $c->stash->{ward} = $c->get_param('ward'); - $c->stash->{category} = $c->get_param('category'); - $c->stash->{q_state} = $c->get_param('state'); + $c->stash->{group_by} = 'category+state'; eval { - my $data = File::Slurp::read_file( - FixMyStreet->path_to( 'data/dashboard.json' )->stringify - ); - my $j = decode_json($data); - if ( !$c->stash->{ward} && !$c->stash->{category} ) { - $c->stash->{problems} = $j->{counts_all}; - } else { - $c->stash->{problems} = $j->{counts_some}; - } - $c->stash->{council} = $j->{council}; - $c->stash->{children} = $j->{wards}; - $c->stash->{category_options} = $j->{category_options}; - if ( lc($c->stash->{q_state}) eq 'all' or !$c->stash->{q_state} ) { - $c->stash->{lists} = $j->{lists}->{all}; - } else { - $c->stash->{lists} = $j->{lists}->{filtered}; - } + my $j = decode_json(path(FixMyStreet->path_to('data/dashboard.json'))->slurp_utf8); + $c->stash($j); }; if ($@) { my $message = _("There was a problem showing this page. Please try again later.") . ' ' . @@ -77,128 +57,246 @@ sub check_page_allowed : Private { $c->detach( '/auth/redirect' ) unless $c->user_exists; $c->detach( '/page_error_404_not_found' ) - unless $c->user_exists && $c->user->from_body; + unless $c->user->from_body || $c->user->is_superuser; - return $c->user->from_body; + 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') }); + } + + return $body; } =head2 index -Show the dashboard table. +Show the summary statistics table. =cut sub index : Path : Args(0) { my ( $self, $c ) = @_; - my $body = $c->forward('check_page_allowed'); - $c->stash->{body} = $body; + if ($c->get_param('export')) { + $c->authenticate(undef, "access_token"); + } - # Set up the data for the dropdowns - $c->stash->{filter_states} = $c->cobrand->state_groups_inspect; + my $body = $c->stash->{body} = $c->forward('check_page_allowed'); - # Just take the first area ID we find - my $area_id = $body->body_areas->first->area_id; + if ($body) { + $c->stash->{body_name} = $body->name; - my $council_detail = mySociety::MaPit::call('area', $area_id ); - $c->stash->{council} = $council_detail; + my $area_id = $body->body_areas->first->area_id; + my $children = mySociety::MaPit::call('area/children', $area_id, + type => $c->cobrand->area_types_children, + ); + $c->stash->{children} = $children; - my $children = mySociety::MaPit::call('area/children', $area_id, - type => $c->cobrand->area_types_children, - ); - $c->stash->{children} = $children; + $c->forward('/admin/fetch_contacts'); + $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; - $c->stash->{all_areas} = { $area_id => $council_detail }; - $c->forward( '/report/new/setup_categories_and_bodies' ); + # See if we've had anything from the body dropdowns + $c->stash->{category} = $c->get_param('category'); + $c->stash->{ward} = $c->get_param('ward'); + if ($c->user->area_id) { + $c->stash->{ward} = $c->user->area_id; + $c->stash->{body_name} = join "", map { $children->{$_}->{name} } grep { $children->{$_} } $c->user->area_id; + } + } else { + $c->forward('/admin/fetch_all_bodies'); + } - # See if we've had anything from the dropdowns + $c->stash->{start_date} = $c->get_param('start_date'); + $c->stash->{end_date} = $c->get_param('end_date'); + $c->stash->{q_state} = $c->get_param('state') || ''; - $c->stash->{ward} = $c->get_param('ward'); - $c->stash->{category} = $c->get_param('category'); + $c->forward('construct_rs_filter'); - my %where = ( - 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ], - ); + if ( $c->get_param('export') ) { + $c->forward('export_as_csv'); + } else { + $c->forward('generate_grouped_data'); + $self->generate_summary_figures($c); + } +} + +sub construct_rs_filter : Private { + my ($self, $c) = @_; + + my %where; $where{areas} = { 'like', '%,' . $c->stash->{ward} . ',%' } if $c->stash->{ward}; $where{category} = $c->stash->{category} if $c->stash->{category}; - $c->stash->{where} = \%where; - my $prob_where = { %where }; - $prob_where->{'me.state'} = $prob_where->{'problem.state'}; - delete $prob_where->{'problem.state'}; - $c->stash->{prob_where} = $prob_where; - my $dtf = $c->model('DB')->storage->datetime_parser; + my $state = $c->stash->{q_state}; + if ( FixMyStreet::DB::Result::Problem->fixed_states->{$state} ) { # Probably fixed - council + $where{'me.state'} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; + } elsif ( $state ) { + $where{'me.state'} = $state; + } else { + $where{'me.state'} = [ FixMyStreet::DB::Result::Problem->visible_states() ]; + } - my %counts; - my $now = DateTime->now( time_zone => FixMyStreet->local_time_zone ); - my $t = $now->clone->truncate( to => 'day' ); - $counts{wtd} = $c->forward( 'updates_search', - [ $dtf->format_datetime( $t->clone->subtract( days => $t->dow - 1 ) ) ] ); - $counts{week} = $c->forward( 'updates_search', - [ $dtf->format_datetime( $now->clone->subtract( weeks => 1 ) ) ] ); - $counts{weeks} = $c->forward( 'updates_search', - [ $dtf->format_datetime( $now->clone->subtract( weeks => 4 ) ) ] ); - $counts{ytd} = $c->forward( 'updates_search', - [ $dtf->format_datetime( $t->clone->set( day => 1, month => 1 ) ) ] ); + my $dtf = $c->model('DB')->storage->datetime_parser; + my $date = DateTime->now( time_zone => FixMyStreet->local_time_zone )->subtract(days => 30); + $date->truncate( to => 'day' ); + + $where{'me.confirmed'} = { '>=', $dtf->format_datetime($date) }; + + my $start_date = $c->stash->{start_date}; + my $end_date = $c->stash->{end_date}; + if ($start_date or $end_date) { + my @parts; + if ($start_date) { + my $date = $dtf->parse_datetime($start_date); + push @parts, { '>=', $dtf->format_datetime( $date ) }; + } + if ($end_date) { + my $one_day = DateTime::Duration->new( days => 1 ); + my $date = $dtf->parse_datetime($end_date); + push @parts, { '<', $dtf->format_datetime( $date + $one_day ) }; + } - $c->stash->{problems} = \%counts; + if (scalar @parts == 2) { + $where{'me.confirmed'} = [ -and => $parts[0], $parts[1] ]; + } else { + $where{'me.confirmed'} = $parts[0]; + } + } - # List of reports underneath summary table + $c->stash->{params} = \%where; + $c->stash->{problems_rs} = $c->cobrand->problems->to_body($c->stash->{body})->search( \%where ); +} - $c->stash->{q_state} = $c->get_param('state') || ''; - if ( $c->stash->{q_state} eq 'fixed - council' ) { - $prob_where->{'me.state'} = [ FixMyStreet::DB::Result::Problem->fixed_states() ]; - } elsif ( $c->stash->{q_state} ) { - $prob_where->{'me.state'} = $c->stash->{q_state}; +sub generate_grouped_data : Private { + my ($self, $c) = @_; + + my $state_map = $c->stash->{state_map} = {}; + $state_map->{$_} = 'open' foreach FixMyStreet::DB::Result::Problem->open_states; + $state_map->{$_} = 'closed' foreach FixMyStreet::DB::Result::Problem->closed_states; + $state_map->{$_} = 'fixed' foreach FixMyStreet::DB::Result::Problem->fixed_states; + + my $group_by = $c->get_param('group_by') || $c->stash->{group_by_default} || ''; + my (%grouped, @groups, %totals); + if ($group_by eq 'category') { + %grouped = map { $_->category => {} } @{$c->stash->{contacts}}; + @groups = qw/category/; + } elsif ($group_by eq 'state') { + @groups = qw/state/; + } elsif ($group_by eq 'month') { + @groups = ( + { extract => \"month from confirmed", -as => 'c_month' }, + { extract => \"year from confirmed", -as => 'c_year' }, + ); + } elsif ($group_by eq 'device+site') { + @groups = qw/cobrand service/; + } elsif ($group_by eq 'device') { + @groups = qw/service/; + } else { + $group_by = 'category+state'; + @groups = qw/category state/; + %grouped = map { $_->category => {} } @{$c->stash->{contacts}}; } - my $params = { - %$prob_where, - 'me.confirmed' => { '>=', $dtf->format_datetime( $now->clone->subtract( days => 30 ) ) }, - }; - my $problems_rs = $c->cobrand->problems->to_body($body)->search( $params ); - my @problems = $problems_rs->all; - - my %problems; - foreach (@problems) { - if ($_->confirmed >= $now->clone->subtract(days => 7)) { - push @{$problems{1}}, $_; - } elsif ($_->confirmed >= $now->clone->subtract(days => 14)) { - push @{$problems{2}}, $_; - } else { - push @{$problems{3}}, $_; + my $problems = $c->stash->{problems_rs}->search(undef, { + group_by => [ map { ref $_ ? $_->{-as} : $_ } @groups ], + select => [ @groups, { count => 'me.id' } ], + as => [ @groups == 2 ? qw/key1 key2 count/ : qw/key1 count/ ], + } ); + $c->stash->{group_by} = $group_by; + + my %columns; + while (my $p = $problems->next) { + my %cols = $p->get_columns; + my ($col1, $col2) = ($cols{key1}, $cols{key2}); + if ($group_by eq 'category+state') { + $col2 = $state_map->{$cols{key2}}; + } elsif ($group_by eq 'month') { + $col1 = Time::Piece->strptime("2017-$cols{key1}-01", '%Y-%m-%d')->fullmonth; } + $grouped{$col1}->{$col2} += $cols{count} if defined $col2; + $grouped{$col1}->{total} += $cols{count}; + $totals{$col2} += $cols{count} if defined $col2; + $totals{total} += $cols{count}; + $columns{$col2} = 1 if defined $col2; } - $c->stash->{lists} = \%problems; - if ( $c->get_param('export') ) { - $self->export_as_csv($c, $problems_rs, $body); + my @columns = keys %columns; + my @rows = keys %grouped; + if ($group_by eq 'month') { + my %months; + my @months = qw/January February March April May June + July August September October November December/; + @months{@months} = (0..11); + @rows = sort { $months{$a} <=> $months{$b} } @rows; + } elsif ($group_by eq 'state') { + my $state_map = $c->stash->{state_map}; + my %map = (confirmed => 0, open => 1, fixed => 2, closed => 3); + @rows = sort { + my $am = $map{$a} // $map{$state_map->{$a}}; + my $bm = $map{$b} // $map{$state_map->{$b}}; + $am <=> $bm; + } @rows; + } else { + @rows = sort @rows; } + $c->stash->{rows} = \@rows; + $c->stash->{columns} = \@columns; + + $c->stash->{grouped} = \%grouped; + $c->stash->{totals} = \%totals; } -sub export_as_csv { - my ($self, $c, $problems_rs, $body) = @_; - require Text::CSV; - my $problems = $problems_rs->search( - {}, { prefetch => 'comments', order_by => 'me.confirmed' }); - - my $filename = do { - my %where = ( - body => $body->id, - category => $c->stash->{category}, - state => $c->stash->{q_state}, - ward => $c->stash->{ward}, - ); - join '-', - $c->req->uri->host, - map { - my $value = $where{$_}; - (defined $value and length $value) ? ($_, $value) : () - } sort keys %where }; +sub generate_summary_figures { + my ($self, $c) = @_; + my $state_map = $c->stash->{state_map}; - my $csv = Text::CSV->new({ binary => 1, eol => "\n" }); - $csv->combine( + # problems this month by state + $c->stash->{"summary_$_"} = 0 for values %$state_map; + + $c->stash->{summary_open} = $c->stash->{problems_rs}->count; + + my $params = $c->stash->{params}; + $params = { map { my $n = $_; s/me\./problem\./ unless /me\.confirmed/; $_ => $params->{$n} } keys %$params }; + + my $comments = $c->model('DB::Comment')->to_body( + $c->stash->{body} + )->search( + { + %$params, + 'me.id' => { 'in' => \"(select min(id) from comment where me.problem_id=comment.problem_id and problem_state not in ('', 'confirmed') group by problem_state)" }, + }, + { + join => 'problem', + group_by => [ 'problem_state' ], + select => [ 'problem_state', { count => 'me.id' } ], + as => [ qw/problem_state count/ ], + } + ); + + while (my $comment = $comments->next) { + my $meta_state = $state_map->{$comment->problem_state}; + next if $meta_state eq 'open'; + $c->stash->{"summary_$meta_state"} += $comment->get_column('count'); + } +} + +sub generate_body_response_time : Private { + my ( $self, $c ) = @_; + + my $avg = $c->stash->{body}->calculate_average; + $c->stash->{body_average} = $avg ? int($avg / 60 / 60 / 24 + 0.5) : 0; +} + +sub export_as_csv : Private { + my ($self, $c) = @_; + + my $csv = $c->stash->{csv} = { + problems => $c->stash->{problems_rs}->search_rs({}, { + prefetch => 'comments', + order_by => 'me.confirmed' + }), + headers => [ 'Report ID', 'Title', 'Detail', @@ -211,159 +309,133 @@ sub export_as_csv { 'Closed', 'Status', 'Latitude', 'Longitude', - 'Nearest Postcode', + 'Query', 'Ward', 'Easting', 'Northing', 'Report URL', + ], + columns => [ + 'id', + 'title', + 'detail', + 'user_name_display', + 'category', + 'created', + 'confirmed', + 'acknowledged', + 'fixed', + 'closed', + 'state', + 'latitude', 'longitude', + 'postcode', + 'wards', + 'local_coords_x', + 'local_coords_y', + 'url', + ], + filename => do { + my %where = ( + category => $c->stash->{category}, + state => $c->stash->{q_state}, + ward => $c->stash->{ward}, ); + $where{body} = $c->stash->{body}->id if $c->stash->{body}; + join '-', + $c->req->uri->host, + map { + my $value = $where{$_}; + (defined $value and length $value) ? ($_, $value) : () + } sort keys %where + }, + }; + $c->forward('generate_csv'); +} + +=head2 generate_csv + +Generates a CSV output, given a 'csv' stash hashref containing: +* filename: filename to be used in output +* problems: a resultset of the rows to output +* headers: an arrayref of the header row strings +* columns: an arrayref of the columns (looked up in the row's as_hashref, plus +the following: user_name_display, acknowledged, fixed, closed, wards, +local_coords_x, local_coords_y, url). + +=cut + +sub generate_csv : Private { + my ($self, $c) = @_; + + my $csv = Text::CSV->new({ binary => 1, eol => "\n" }); + $csv->combine(@{$c->stash->{csv}->{headers}}); my @body = ($csv->string); my $fixed_states = FixMyStreet::DB::Result::Problem->fixed_states; my $closed_states = FixMyStreet::DB::Result::Problem->closed_states; + my $wards = 0; + my $comments = 0; + foreach (@{$c->stash->{csv}->{columns}}) { + $wards = 1 if $_ eq 'wards'; + $comments = 1 if $_ eq 'acknowledged'; + } + + my $problems = $c->stash->{csv}->{problems}; while ( my $report = $problems->next ) { - my $external_body; - my $body_name = ""; - if ( $external_body = $report->body($c) ) { - # seems to be a zurich specific thing - $body_name = $external_body->name if ref $external_body; - } my $hashref = $report->as_hashref($c); - $hashref->{user_name_display} = $report->anonymous? - '(anonymous)' : $report->user->name; - - for my $comment ($report->comments) { - my $problem_state = $comment->problem_state or next; - next if $problem_state eq 'confirmed'; - $hashref->{acknowledged_pp} //= $c->cobrand->prettify_dt( $comment->created ); - $hashref->{fixed_pp} //= $fixed_states->{ $problem_state } ? - $c->cobrand->prettify_dt( $comment->created ): undef; - if ($closed_states->{ $problem_state }) { - $hashref->{closed_pp} = $c->cobrand->prettify_dt( $comment->created ); - last; + $hashref->{user_name_display} = $report->anonymous + ? '(anonymous)' : $report->user->name; + + if ($comments) { + for my $comment ($report->comments) { + my $problem_state = $comment->problem_state or next; + next unless $comment->state eq 'confirmed'; + next if $problem_state eq 'confirmed'; + $hashref->{acknowledged} //= $comment->confirmed; + $hashref->{fixed} //= $fixed_states->{ $problem_state } || $comment->mark_fixed ? + $comment->confirmed : undef; + if ($closed_states->{ $problem_state }) { + $hashref->{closed} = $comment->confirmed; + last; + } } } - my $wards = join ', ', - map { $c->stash->{children}->{$_}->{name} } - grep {$c->stash->{children}->{$_} } - split ',', $hashref->{areas}; + if ($wards) { + $hashref->{wards} = join ', ', + map { $c->stash->{children}->{$_}->{name} } + grep {$c->stash->{children}->{$_} } + split ',', $hashref->{areas}; + } - my @local_coords = $report->local_coords; + ($hashref->{local_coords_x}, $hashref->{local_coords_y}) = + $report->local_coords; + $hashref->{url} = join '', $c->cobrand->base_url_for_report($report), $report->url; $csv->combine( @{$hashref}{ - 'id', - 'title', - 'detail', - 'user_name_display', - 'category', - 'created_pp', - 'confirmed_pp', - 'acknowledged_pp', - 'fixed_pp', - 'closed_pp', - 'state', - 'latitude', 'longitude', - 'postcode', - }, - $wards, - $local_coords[0], - $local_coords[1], - (join '', $c->cobrand->base_url_for_report($report), $report->url), + @{$c->stash->{csv}->{columns}} + }, ); push @body, $csv->string; } + + my $filename = $c->stash->{csv}->{filename}; $c->res->content_type('text/csv; charset=utf-8'); $c->res->header('content-disposition' => "attachment; filename=${filename}.csv"); $c->res->body( join "", @body ); } -sub updates_search : Private { - my ( $self, $c, $time ) = @_; - - my $body = $c->stash->{body}; - - my $params = { - %{$c->stash->{where}}, - 'me.confirmed' => { '>=', $time }, - }; - - my $comments = $c->model('DB::Comment')->to_body($body)->search( - $params, - { - group_by => [ 'problem_state' ], - select => [ 'problem_state', { count => 'me.id' } ], - as => [ qw/state state_count/ ], - join => 'problem' - } - ); - - my %counts = - map { ($_->state||'-') => $_->get_column('state_count') } $comments->all; - %counts = - map { $_ => $counts{$_} || 0 } - ('confirmed', 'investigating', 'in progress', 'closed', 'fixed - council', - 'fixed - user', 'fixed', 'unconfirmed', 'hidden', - 'partial', 'action scheduled', 'planned'); - - $counts{'action scheduled'} += $counts{planned} || 0; - - for my $vars ( - [ 'time_to_fix', 'fixed - council' ], - [ 'time_to_mark', 'in progress', 'action scheduled', 'investigating', 'closed' ], - ) { - my $col = shift @$vars; - my $substmt = "select min(id) from comment where me.problem_id=comment.problem_id and problem_state in ('" - . join("','", @$vars) . "')"; - $comments = $c->model('DB::Comment')->to_body($body)->search( - { %$params, - problem_state => $vars, - 'me.id' => \"= ($substmt)", - }, - { - select => [ - { count => 'me.id' }, - { avg => { extract => "epoch from me.confirmed-problem.confirmed" } }, - ], - as => [ qw/state_count time/ ], - join => 'problem' - } - )->first; - $counts{$col} = int( ($comments->get_column('time')||0) / 60 / 60 / 24 + 0.5 ); - } - - $counts{fixed_user} = $c->model('DB::Comment')->to_body($body)->search( - { %$params, mark_fixed => 1, problem_state => undef }, { join => 'problem' } - )->count; - - $params = { - %{$c->stash->{prob_where}}, - 'me.confirmed' => { '>=', $time }, - }; - $counts{total} = $c->cobrand->problems->to_body($body)->search( $params )->count; - - $params = { - %{$c->stash->{prob_where}}, - 'me.confirmed' => { '>=', $time }, - state => 'confirmed', - '(select min(id) from comment where me.id=problem_id and problem_state is not null)' => undef, - }; - $counts{not_marked} = $c->cobrand->problems->to_body($body)->search( $params )->count; - - return \%counts; -} - =head1 AUTHOR Matthew Somerville =head1 LICENSE -Copyright (c) 2012 UK Citizens Online Democracy. All rights reserved. +Copyright (c) 2017 UK Citizens Online Democracy. All rights reserved. Licensed under the Affero GPL. =cut diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm index c457c8fce..8d5b0b147 100644 --- a/perllib/FixMyStreet/App/Controller/Location.pm +++ b/perllib/FixMyStreet/App/Controller/Location.pm @@ -96,7 +96,6 @@ sub determine_location_from_pc : Private { if ( ref($error) eq 'ARRAY' ) { foreach (@$error) { my $a = $_->{address}; - $a = decode_utf8($a) if !utf8::is_utf8($a); $a =~ s/, United Kingdom//; $a =~ s/, UK//; $_->{address} = $a; @@ -111,6 +110,21 @@ sub determine_location_from_pc : Private { return; } +sub determine_location_from_bbox : Private { + my ( $self, $c ) = @_; + + my $bbox = $c->get_param('bbox'); + return unless $bbox; + + my ($min_lon, $min_lat, $max_lon, $max_lat) = split /,/, $bbox; + my $longitude = ($max_lon + $min_lon ) / 2; + my $latitude = ($max_lat + $min_lat ) / 2; + $c->stash->{bbox} = $bbox; + $c->stash->{latitude} = $latitude; + $c->stash->{longitude} = $longitude; + return $c->forward('check_location'); +} + =head2 check_location Just make sure that for UK installs, our co-ordinates are indeed in the UK. diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index e2ab16b6b..a8e0b7a3c 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -21,10 +21,7 @@ data to change. (Authentication requires: - user to be from_body - - user to have a "moderate" record in user_body_permissions (there is - currently no admin interface for this. Should be added, but - while we're trialing this, it's a simple case of adding a DB record - manually) + - user to have a "moderate" record in user_body_permissions The original data of the report is stored in moderation_original_data, so that it can be reverted/consulted if required. All moderation events are @@ -85,7 +82,7 @@ sub moderate_report : Chained('report') : PathPart('') : Args(0) { sub moderating_user_name { my $user = shift; - return $user->from_body ? $user->from_body->name : 'a FixMyStreet administrator'; + return $user->from_body ? $user->from_body->name : _('an administrator'); } sub report_moderate_audit : Private { @@ -106,19 +103,21 @@ sub report_moderate_audit : Private { reason => (sprintf '%s (%s)', $reason, $types_csv), }); - my $token = $c->model("DB::Token")->create({ - scope => 'moderation', - data => { id => $problem->id } - }); - - $c->send_email( 'problem-moderated.txt', { - to => [ [ $problem->user->email, $problem->name ] ], - types => $types_csv, - user => $problem->user, - problem => $problem, - report_uri => $c->stash->{report_uri}, - report_complain_uri => $c->stash->{cobrand_base} . '/contact?m=' . $token->token, - }); + if ($problem->user->email_verified && $c->cobrand->send_moderation_notifications) { + my $token = $c->model("DB::Token")->create({ + scope => 'moderation', + data => { id => $problem->id } + }); + + $c->send_email( 'problem-moderated.txt', { + to => [ [ $problem->user->email, $problem->name ] ], + types => $types_csv, + user => $problem->user, + problem => $problem, + report_uri => $c->stash->{report_uri}, + report_complain_uri => $c->stash->{cobrand_base} . '/contact?m=' . $token->token, + }); + } } sub report_moderate_hide : Private { diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm index 5b80a4a08..1693766ba 100644 --- a/perllib/FixMyStreet/App/Controller/My.pm +++ b/perllib/FixMyStreet/App/Controller/My.pm @@ -176,6 +176,10 @@ sub setup_page_data : Private { any_zoom => 1, ) if @$pins; + + foreach (qw(flash_message)) { + $c->stash->{$_} = $c->flash->{$_} if $c->flash->{$_}; + } } sub planned_change : Path('planned/change') { @@ -185,17 +189,21 @@ sub planned_change : Path('planned/change') { $c->go('planned') if grep { /^shortlist-(up|down|\d+)$/ } keys %{$c->req->params}; my $id = $c->get_param('id'); - $c->forward( '/report/load_problem_or_display_error', [ $id ] ); - my $add = $c->get_param('shortlist-add'); my $remove = $c->get_param('shortlist-remove'); - $c->detach('/page_error_403_access_denied', []) - unless $add || $remove; - if ($add) { + # we can't lookup the report for removing via load_problem_or_display_error + # as then there is no way to remove a report that has been hidden or moved + # to another body by a category change from the shortlist. + if ($remove) { + my $report = $c->model('DB::Problem')->find({ id => $id }) + or $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ); + $c->user->remove_from_planned_reports($report); + } elsif ($add) { + $c->forward( '/report/load_problem_or_display_error', [ $id ] ); $c->user->add_to_planned_reports($c->stash->{problem}); - } elsif ($remove) { - $c->user->remove_from_planned_reports($c->stash->{problem}); + } else { + $c->detach('/page_error_403_access_denied', []); } if ($c->get_param('ajax')) { diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm index dceccc81f..d50d0d03f 100644 --- a/perllib/FixMyStreet/App/Controller/Offline.pm +++ b/perllib/FixMyStreet/App/Controller/Offline.pm @@ -11,7 +11,6 @@ FixMyStreet::App::Controller::Offline - Catalyst Controller =head1 DESCRIPTION Offline pages Catalyst Controller. -On staging site, appcache only for people who want it. =head1 METHODS @@ -20,7 +19,7 @@ On staging site, appcache only for people who want it. sub have_appcache : Private { my ($self, $c) = @_; return $c->user_exists && $c->user->has_body_permission_to('planned_reports') - && !FixMyStreet->staging_flag('enable_appcache', 0); + && !($c->user->is_superuser && FixMyStreet->staging_flag('enable_appcache', 0)); } sub manifest : Path("/offline/appcache.manifest") { diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index e37e08698..b1cc5885a 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -326,6 +326,8 @@ sub inspect : Private { $c->stash->{has_default_priority} = scalar( grep { $_->is_default } $problem->response_priorities ); } + $c->stash->{max_detailed_info_length} = $c->cobrand->max_detailed_info_length; + if ( $c->get_param('save') ) { $c->forward('/auth/check_csrf_token'); @@ -335,8 +337,20 @@ sub inspect : Private { my %update_params = (); if ($permissions->{report_inspect}) { - foreach (qw/detailed_information traffic_information/) { - $problem->set_extra_metadata( $_ => $c->get_param($_) ); + $problem->set_extra_metadata( traffic_information => $c->get_param('traffic_information') ); + + if ( my $info = $c->get_param('detailed_information') ) { + $problem->set_extra_metadata( detailed_information => $info ); + if ($c->cobrand->max_detailed_info_length && + length($info) > $c->cobrand->max_detailed_info_length + ) { + $valid = 0; + push @{ $c->stash->{errors} }, + sprintf( + _('Detailed information is limited to %d characters.'), + $c->cobrand->max_detailed_info_length + ); + } } if ( $c->get_param('defect_type') ) { @@ -363,15 +377,15 @@ sub inspect : Private { if ( $problem->state eq 'hidden' ) { $problem->get_photoset->delete_cached; } - if ( $problem->state eq 'duplicate' && $old_state ne 'duplicate' ) { - # If the report is being closed as duplicate, make sure the - # update records this. - $update_params{problem_state} = "duplicate"; - } - if ( $problem->state ne 'duplicate' ) { + if ( $problem->state eq 'duplicate') { + if (my $duplicate_of = $c->get_param('duplicate_of')) { + $problem->set_duplicate_of($duplicate_of); + } elsif (not $c->get_param('public_update')) { + $valid = 0; + push @{ $c->stash->{errors} }, _('Please provide a duplicate ID or public update for this report.'); + } + } else { $problem->unset_extra_metadata('duplicate_of'); - } elsif (my $duplicate_of = $c->get_param('duplicate_of')) { - $problem->set_duplicate_of($duplicate_of); } if ( $problem->state ne $old_state ) { @@ -410,7 +424,11 @@ sub inspect : Private { } if ($permissions->{report_inspect} || $permissions->{report_edit_category}) { - $c->forward( '/admin/report_edit_category', [ $problem ] ); + $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + + if ($c->stash->{update_text}) { + $update_text .= "\n\n" . $c->stash->{update_text}; + } # The new category might require extra metadata (e.g. pothole size), so # we need to update the problem with the new values. @@ -502,25 +520,28 @@ sub nearby_json : Private { my $p = $c->stash->{problem}; my $dist = 1; + # This is for the list template, this is a list on that page. + $c->stash->{page} = 'report'; + my $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, [ $p->id ], 5, $p->latitude, $p->longitude, undef, [ $p->category ], undef + $c, $dist, [ $p->id ], 5, $p->latitude, $p->longitude, [ $p->category ], undef ); + # Want to treat these as if they were on map + $nearby = [ map { $_->problem } @$nearby ]; my @pins = map { - my $p = $_->problem; - my $colour = $c->cobrand->pin_colour( $p, 'around' ); - [ $p->latitude, $p->longitude, - $colour, - $p->id, $p->title_safe, 'small', JSON->false + my $p = $_->pin_data($c, 'around'); + [ $p->{latitude}, $p->{longitude}, $p->{colour}, + $p->{id}, $p->{title}, 'small', JSON->false ] } @$nearby; - my $on_map_list_html = $c->render_fragment( + my $list_html = $c->render_fragment( 'around/on_map_list_items.html', - { on_map => [], around_map => $nearby } + { around_map => [], on_map => $nearby } ); my $json = { pins => \@pins }; - $json->{reports_list} = $on_map_list_html if $on_map_list_html; + $json->{reports_list} = $list_html if $list_html; my $body = encode_json($json); $c->res->content_type('application/json; charset=utf-8'); $c->res->body($body); diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index f92a5cb22..ca4fa2fd2 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -13,6 +13,7 @@ use Path::Class; use Utils; use mySociety::EmailUtil; use JSON::MaybeXS; +use FixMyStreet::SMS; =head1 NAME @@ -116,19 +117,25 @@ sub report_new : Path : Args(0) { $c->forward('redirect_or_confirm_creation'); } -# This is for the new phonegap versions of the app. It looks a lot like -# report_new but there's a few workflow differences as we only ever want -# to sent JSON back here - sub report_new_test : Path('_test_') : Args(0) { my ( $self, $c ) = @_; $c->stash->{template} = 'email_sent.html'; $c->stash->{email_type} = $c->get_param('email_type'); } +# This is for the new phonegap versions of the app. It looks a lot like +# report_new but there's a few workflow differences as we only ever want +# to sent JSON back here + sub report_new_ajax : Path('mobile') : Args(0) { my ( $self, $c ) = @_; + # Apps are sending email as username + # Prepare for when they upgrade + if (!$c->get_param('username')) { + $c->set_param('username', $c->get_param('email')); + } + # create the report - loading a partial if available $c->forward('initialize_report'); @@ -250,6 +257,7 @@ sub category_extras_ajax : Path('category_extras') : Args(0) { }; my $category_extra = ''; + my $category_extra_json = []; my $generate; if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) { $c->stash->{category_extras} = { $category => $c->stash->{category_extras}->{$category} }; @@ -263,6 +271,7 @@ sub category_extras_ajax : Path('category_extras') : Args(0) { } if ($generate) { $category_extra = $c->render_fragment('report/new/category_extras.html', $vars); + $category_extra_json = $c->forward('generate_category_extra_json'); } my $councils_text = $c->render_fragment( 'report/new/councils_text.html', $vars); @@ -272,6 +281,7 @@ sub category_extras_ajax : Path('category_extras') : Args(0) { category_extra => $category_extra, councils_text => $councils_text, councils_text_private => $councils_text_private, + category_extra_json => $category_extra_json, }); $c->res->content_type('application/json; charset=utf-8'); @@ -354,8 +364,12 @@ sub report_import : Path('/import') { my $report_user = $c->model('DB::User')->find_or_create( { email => lc $input{email}, + email_verified => 1, name => $input{name}, phone => $input{phone} + }, + { + key => 'users_email_verified_key' } ); @@ -447,7 +461,7 @@ sub initialize_report : Private { if ($report) { # log the problem creation user in to the site - $c->authenticate( { email => $report->user->email }, + $c->authenticate( { email => $report->user->email, email_verified => 1 }, 'no_password' ); # save the token to delete at the end @@ -653,7 +667,7 @@ sub setup_categories_and_bodies : Private { $bodies_to_list{ $contact->body_id } = $contact->body; unless ( $seen{$contact->category} ) { - push @category_options, { name => $contact->category, value => $contact->category_display }; + push @category_options, { name => $contact->category, value => $contact->category_display, group => $contact->get_extra_metadata('group') || '' }; my $metas = $contact->get_metadata_for_input; $category_extras{$contact->category} = $metas if @$metas; @@ -671,9 +685,9 @@ sub setup_categories_and_bodies : Private { if (@category_options) { # If there's an Other category present, put it at the bottom @category_options = ( - { name => _('-- Pick a category --'), value => _('-- Pick a category --') }, + { name => _('-- Pick a category --'), value => _('-- Pick a category --'), group => '' }, grep { $_->{name} ne _('Other') } @category_options ); - push @category_options, { name => _('Other'), value => $seen{_('Other')} } if $seen{_('Other')}; + push @category_options, { name => _('Other'), value => $seen{_('Other')}, group => _('Other') } if $seen{_('Other')}; } $c->cobrand->call_hook(munge_category_list => \@category_options, \@contacts, \%category_extras); @@ -694,6 +708,20 @@ 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) { + push @{$category_groups{$category->{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; + } } sub setup_report_extra_fields : Private { @@ -733,14 +761,12 @@ sub process_user : Private { # Extract all the params to a hash to make them easier to work with my %params = map { $_ => $c->get_param($_) } - ( 'email', 'name', 'phone', 'password_register', 'fms_extra_title' ); - - my $user_title = Utils::trim_text( $params{fms_extra_title} ); + ( 'username', 'email', 'name', 'phone', 'password_register', 'fms_extra_title' ); if ( $c->cobrand->allow_anonymous_reports ) { my $anon_details = $c->cobrand->anonymous_account; - for my $key ( qw( email name ) ) { + for my $key ( qw( username email name ) ) { $params{ $key } ||= $anon_details->{ $key }; } } @@ -755,34 +781,29 @@ sub process_user : Private { last; } - $user->name( Utils::trim_text( $params{name} ) ) if $params{name}; - $user->phone( Utils::trim_text( $params{phone} ) ); - $user->title( $user_title ) if $user_title; $report->user( $user ); + $c->forward('update_user', [ \%params ]); if ($c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies}) or $c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies})) { $report->name($user->from_body->name); $user->name($user->from_body->name) unless $user->name; $c->stash->{no_reporter_alert} = 1; - } else { - $report->name($user->name); } return 1; } } - # cleanup the email address - my $email = $params{email} ? lc $params{email} : ''; - $email =~ s{\s+}{}g; - - $report->user( $c->model('DB::User')->find_or_new( { email => $email } ) ) + my $parsed = FixMyStreet::SMS->parse_username($params{username}); + my $type = $parsed->{type} || 'email'; + $type = 'email' unless FixMyStreet->config('SMS_AUTHENTICATION'); + $report->user( $c->model('DB::User')->find_or_new( { $type => $parsed->{username} } ) ) unless $report->user; - # The user is trying to sign in. We only care about email from the params. + # The user is trying to sign in. We only care about username from the params. if ( $c->get_param('submit_sign_in') || $c->get_param('password_sign_in') ) { - unless ( $c->forward( '/auth/sign_in' ) ) { - $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. If you cannot remember your password, or do not have one, please fill in the ‘sign in by email’ section of the form.'); + 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.'); return 1; } my $user = $c->user->obj; @@ -794,17 +815,28 @@ sub process_user : Private { return 1; } - # set the user's name, phone, and password - $report->user->name( Utils::trim_text( $params{name} ) ) if $params{name}; - $report->user->phone( Utils::trim_text( $params{phone} ) ); + $c->forward('update_user', [ \%params ]); $report->user->password( Utils::trim_text( $params{password_register} ) ) if $params{password_register}; - $report->user->title( $user_title ) if $user_title; - $report->name( Utils::trim_text( $params{name} ) ); return 1; } +sub update_user : Private { + my ($self, $c, $params) = @_; + my $report = $c->stash->{report}; + my $user = $report->user; + $user->name( Utils::trim_text( $params->{name} ) ); + $report->name($user->name); + if (!$user->phone_verified) { + $user->phone( Utils::trim_text( $params->{phone} ) ); + } elsif (!$user->email_verified) { + $user->email( Utils::trim_text( $params->{email} ) ); + } + my $user_title = Utils::trim_text( $params->{fms_extra_title} ); + $user->title( $user_title ) if $user_title; +} + =head2 process_report Looking at the parameters passed in create a new item and return it. Does not @@ -1027,11 +1059,11 @@ sub check_for_errors : Private { delete $field_errors{name}; } - # if using social login then we don't care about name and email errors + # 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'); if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; - delete $field_errors{email}; + delete $field_errors{username}; } # add the photo error if there is one. @@ -1052,7 +1084,8 @@ sub tokenize_user : Private { my ($self, $c, $report) = @_; $c->stash->{token_data} = { name => $report->user->name, - phone => $report->user->phone, + (!$report->user->phone_verified ? (phone => $report->user->phone) : ()), + (!$report->user->email_verified ? (email => $report->user->email) : ()), password => $report->user->password, title => $report->user->title, }; @@ -1085,6 +1118,104 @@ sub send_problem_confirm_email : Private { } ); } +sub send_problem_confirm_text : Private { + my ( $self, $c ) = @_; + my $data = $c->stash->{token_data} || {}; + my $report = $c->stash->{report}; + + $data->{id} = $report->id; + $c->forward('/auth/phone/send_token', [ $data, 'problem', $report->user->phone ]); + $c->stash->{submit_url} = '/report/new/text'; +} + +sub confirm_by_text : Path('text') { + my ( $self, $c ) = @_; + + $c->stash->{submit_url} = '/report/new/text'; + $c->forward('/auth/phone/code', [ 'problem', '/report/new/process_confirmation' ]); +} + +sub process_confirmation : Private { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'tokens/confirm_problem.html'; + my $data = $c->stash->{token_data}; + + unless ($c->stash->{report}) { + # Look at all problems, not just cobrand, in case am approving something we don't actually show + $c->stash->{report} = $c->model('DB::Problem')->find({ id => $data->{id} }) || return; + } + my $problem = $c->stash->{report}; + + # check that this email or domain are not the cause of abuse. If so hide it. + if ( $problem->is_from_abuser ) { + $problem->update( + { state => 'hidden', lastupdate => \'current_timestamp' } ); + $c->stash->{template} = 'tokens/abuse.html'; + return; + } + + # For Zurich, email confirmation simply sets a flag, it does not change the + # problem state, log in, or anything else + if ($c->cobrand->moniker eq 'zurich') { + $problem->set_extra_metadata( email_confirmed => 1 ); + $problem->update( { + confirmed => \'current_timestamp', + } ); + + if ( $data->{name} || $data->{password} ) { + $problem->user->name( $data->{name} ) if $data->{name}; + $problem->user->phone( $data->{phone} ) if $data->{phone}; + $problem->user->update; + } + + return 1; + } + + if ($problem->state ne 'unconfirmed') { + my $report_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url; + $c->res->redirect($report_uri); + return; + } + + # We have an unconfirmed problem + $problem->update( + { + state => 'confirmed', + confirmed => \'current_timestamp', + lastupdate => \'current_timestamp', + } + ); + + # Subscribe problem reporter to email updates + $c->forward( '/report/new/create_reporter_alert' ); + + # log the problem creation user in to the site + if ( $data->{name} || $data->{password} ) { + if (!$problem->user->email_verified) { + $problem->user->email( $data->{email} ) if $data->{email}; + } elsif (!$problem->user->phone_verified) { + $problem->user->phone( $data->{phone} ) if $data->{phone}; + } + $problem->user->password( $data->{password}, 1 ) if $data->{password}; + for (qw(name title facebook_id twitter_id)) { + $problem->user->$_( $data->{$_} ) if $data->{$_}; + } + $problem->user->update; + } + if ($problem->user->email_verified) { + $c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' ); + } elsif ($problem->user->phone_verified) { + $c->authenticate( { phone => $problem->user->phone, phone_verified => 1 }, 'no_password' ); + } else { + warn "Reached user authentication with no username verification"; + } + $c->set_session_cookie_expire(0); + + $c->stash->{created_report} = 'fromemail'; + return 1; +} + =head2 save_user_and_report Save the user and the report. @@ -1131,19 +1262,15 @@ sub save_user_and_report : Private { $c->stash->{detach_args} = [$token->token]; if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/facebook_sign_in'); + $c->detach('/auth/social/facebook_sign_in'); } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/twitter_sign_in'); + $c->detach('/auth/social/twitter_sign_in'); } } # Save or update the user if appropriate if ( $c->cobrand->never_confirm_reports ) { - if ( $report->user->in_storage() ) { - $report->user->update(); - } else { - $report->user->insert(); - } + $report->user->update_or_insert; $report->confirm(); } elsif ( $c->forward('created_as_someone_else', [ $c->stash->{bodies} ]) ) { # If created on behalf of someone else, we automatically confirm it, @@ -1153,7 +1280,11 @@ sub save_user_and_report : Private { # User does not exist. $c->forward('tokenize_user', [ $report ]); $report->user->name( undef ); - $report->user->phone( undef ); + if (!$report->user->email_verified) { + $report->user->email( undef ); + } elsif (!$report->user->phone_verified) { + $report->user->phone( undef ); + } $report->user->password( '', 1 ); $report->user->title( undef ); $report->user->insert(); @@ -1173,8 +1304,7 @@ sub save_user_and_report : Private { $c->log->info($report->user->id . ' exists, but is not logged in for this report'); } - # save the report; - $report->in_storage ? $report->update : $report->insert(); + $report->update_or_insert; # tidy up if ( my $token = $c->stash->{partial_token} ) { @@ -1249,9 +1379,13 @@ sub redirect_or_confirm_creation : Private { to => [ [ $report->user->email, $report->name ] ], } ); } - if ($c->user_exists && $c->user->has_body_permission_to('planned_reports')) { + # If the user has shortlist permission, and either we're not on a + # council cobrand or the just-created problem is owned by the cobrand + # (so we'll stay on-cobrand), redirect to the problem. + if ($c->user_exists && $c->user->has_body_permission_to('planned_reports') && + (!$c->cobrand->is_council || $c->cobrand->owns_problem($report))) { $c->log->info($report->user->id . ' is an inspector - redirecting straight to report page for ' . $report->id); - $c->res->redirect( '/report/'. $report->id ); + $c->res->redirect( $report->url ); } else { $c->log->info($report->user->id . ' was logged in, showing confirmation page for ' . $report->id); $c->stash->{created_report} = 'loggedin'; @@ -1260,13 +1394,20 @@ sub redirect_or_confirm_creation : Private { return 1; } - # otherwise email a confirm token to them. - $c->forward( 'send_problem_confirm_email' ); - - # tell user that they've been sent an email - $c->stash->{template} = 'email_sent.html'; - $c->stash->{email_type} = 'problem'; - $c->log->info($report->user->id . ' created ' . $report->id . ', email sent, ' . ($c->stash->{token_data}->{password} ? 'password set' : 'password not set')); + # otherwise email or text a confirm token to them. + my $thing = 'email'; + if ($report->user->email_verified) { + $c->forward( 'send_problem_confirm_email' ); + # tell user that they've been sent an email + $c->stash->{template} = 'email_sent.html'; + $c->stash->{email_type} = 'problem'; + } elsif ($report->user->phone_verified) { + $c->forward( 'send_problem_confirm_text' ); + $thing = 'text'; + } else { + warn "Reached problem confirmation with no username verification"; + } + $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 { @@ -1319,6 +1460,24 @@ sub redirect_to_around : Private { return $c->res->redirect($around_uri); } +sub generate_category_extra_json : Private { + my ( $self, $c ) = @_; + + my $true = JSON->true; + my $false = JSON->false; + + my @fields = map { + { + %$_, + required => $_->{required} eq "true" ? $true : $false, + variable => $_->{variable} eq "true" ? $true : $false, + order => int($_->{order}), + } + } @{ $c->stash->{category_extras}->{$c->stash->{category}} }; + + return \@fields; +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index 033f5c017..c28039808 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -36,18 +36,6 @@ sub report_update : Path : Args(0) { $c->forward('redirect_or_confirm_creation'); } -sub confirm : Private { - my ( $self, $c ) = @_; - - $c->stash->{update}->confirm; - $c->stash->{update}->update; - - $c->forward('update_problem'); - $c->forward('signup_for_alerts'); - - return 1; -} - sub update_problem : Private { my ( $self, $c ) = @_; @@ -109,6 +97,10 @@ sub process_user : Private { my $update = $c->stash->{update}; + # Extract all the params to a hash to make them easier to work with + my %params = map { $_ => $c->get_param($_) } + ( 'username', 'name', 'password_register', 'fms_extra_title' ); + # Extra block to use 'last' if ( $c->user_exists ) { { my $user = $c->user->obj; @@ -118,13 +110,9 @@ sub process_user : Private { last; } - my $name = $c->get_param('name'); - $user->name( Utils::trim_text( $name ) ) if $name; - my $title = $c->get_param('fms_extra_title'); - if ( $title ) { - $c->log->debug( 'user exists and title is ' . $title ); - $user->title( Utils::trim_text( $title ) ); - } + $user->name( Utils::trim_text( $params{name} ) ) if $params{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 @@ -135,21 +123,16 @@ sub process_user : Private { return 1; } } - # Extract all the params to a hash to make them easier to work with - my %params = map { $_ => $c->get_param($_) } - ( 'rznvy', 'name', 'password_register', 'fms_extra_title' ); - - # cleanup the email address - my $email = $params{rznvy} ? lc $params{rznvy} : ''; - $email =~ s{\s+}{}g; - - $update->user( $c->model('DB::User')->find_or_new( { email => $email } ) ) + my $parsed = FixMyStreet::SMS->parse_username($params{username}); + my $type = $parsed->{type} || 'email'; + $type = 'email' unless FixMyStreet->config('SMS_AUTHENTICATION'); + $update->user( $c->model('DB::User')->find_or_new( { $type => $parsed->{username} } ) ) unless $update->user; - # The user is trying to sign in. We only care about email from the params. + # The user is trying to sign in. We only care about username from the params. if ( $c->get_param('submit_sign_in') || $c->get_param('password_sign_in') ) { - unless ( $c->forward( '/auth/sign_in', [ $email ] ) ) { - $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. If you cannot remember your password, or do not have one, please fill in the ‘sign in by email’ section of the form.'); + 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.'); return 1; } my $user = $c->user->obj; @@ -328,8 +311,6 @@ sub process_update : Private { $update->extra( $extra ); } - $c->log->debug( 'name is ' . $c->get_param('name') ); - $c->stash->{add_alert} = $c->get_param('add_alert'); return 1; @@ -372,7 +353,7 @@ sub check_for_errors : Private { $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in'); if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; - delete $field_errors{email}; + delete $field_errors{username}; } if ( my $photo_error = delete $c->stash->{photo_error} ) { @@ -438,18 +419,14 @@ sub save_update : Private { $c->stash->{detach_args} = [$token->token]; if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/facebook_sign_in'); + $c->detach('/auth/social/facebook_sign_in'); } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/twitter_sign_in'); + $c->detach('/auth/social/twitter_sign_in'); } } if ( $c->cobrand->never_confirm_updates ) { - if ( $update->user->in_storage() ) { - $update->user->update(); - } else { - $update->user->insert(); - } + $update->user->update_or_insert; $update->confirm(); } elsif ( $c->forward('/report/new/created_as_someone_else', [ $update->problem->bodies_str ]) ) { # If created on behalf of someone else, we automatically confirm it, @@ -464,7 +441,6 @@ sub save_update : Private { } elsif ( $c->user && $c->user->id == $update->user->id ) { # Logged in and same user, so can confirm update straight away - $c->log->debug( 'user exists' ); $update->user->update; $update->confirm; } else { @@ -473,12 +449,7 @@ sub save_update : Private { $update->user->discard_changes(); } - if ( $update->in_storage ) { - $update->update; - } - else { - $update->insert; - } + $update->update_or_insert; return 1; } @@ -507,28 +478,98 @@ sub redirect_or_confirm_creation : Private { return 1; } - # otherwise create a confirm token and email it to them. - my $data = $c->stash->{token_data} || {}; - my $token = $c->model("DB::Token")->create( - { - scope => 'comment', - data => { - %$data, - id => $update->id, - add_alert => ( $c->get_param('add_alert') ? 1 : 0 ), - } - } - ); + my $data = $c->stash->{token_data}; + $data->{id} = $update->id; + $data->{add_alert} = $c->get_param('add_alert') ? 1 : 0; + + if ($update->user->email_verified) { + $c->forward('send_confirmation_email'); + # tell user that they've been sent an email + $c->stash->{template} = 'email_sent.html'; + $c->stash->{email_type} = 'update'; + } elsif ($update->user->phone_verified) { + $c->forward('send_confirmation_text'); + } else { + warn "Reached update confirmation with no username verification"; + } + + return 1; +} + +sub send_confirmation_email : Private { + my ( $self, $c ) = @_; + + my $update = $c->stash->{update}; + my $token = $c->model("DB::Token")->create( { + scope => 'comment', + data => $c->stash->{token_data}, + } ); + my $template = 'update-confirm.txt'; $c->stash->{token_url} = $c->uri_for_email( '/C', $token->token ); - $c->send_email( 'update-confirm.txt', { - to => $update->name - ? [ [ $update->user->email, $update->name ] ] - : $update->user->email, + $c->send_email( $template, { + to => [ $update->name ? [ $update->user->email, $update->name ] : $update->user->email ], } ); +} + +sub send_confirmation_text : Private { + my ( $self, $c ) = @_; + my $update = $c->stash->{update}; + $c->forward('/auth/phone/send_token', [ $c->stash->{token_data}, 'comment', $update->user->phone ]); + $c->stash->{submit_url} = '/report/update/text'; +} - # tell user that they've been sent an email - $c->stash->{template} = 'email_sent.html'; - $c->stash->{email_type} = 'update'; +sub confirm_by_text : Path('text') { + my ( $self, $c ) = @_; + + $c->stash->{submit_url} = '/report/update/text'; + $c->forward('/auth/phone/code', [ 'comment', '/report/update/process_confirmation' ]); +} + +sub process_confirmation : Private { + my ( $self, $c ) = @_; + + $c->stash->{template} = 'tokens/confirm_update.html'; + my $data = $c->stash->{token_data}; + + unless ($c->stash->{update}) { + $c->stash->{update} = $c->model('DB::Comment')->find({ id => $data->{id} }) || return; + } + my $comment = $c->stash->{update}; + + # check that this email or domain are not the cause of abuse. If so hide it. + if ( $comment->is_from_abuser ) { + $c->stash->{template} = 'tokens/abuse.html'; + return; + } + + if ( $comment->state ne 'unconfirmed' ) { + my $report_uri = $c->cobrand->base_url_for_report( $comment->problem ) . $comment->problem->url; + $c->res->redirect($report_uri); + return; + } + + if ( $data->{name} || $data->{password} ) { + for (qw(name facebook_id twitter_id)) { + $comment->user->$_( $data->{$_} ) if $data->{$_}; + } + $comment->user->password( $data->{password}, 1 ) if $data->{password}; + $comment->user->update; + } + + if ($comment->user->email_verified) { + $c->authenticate( { email => $comment->user->email, email_verified => 1 }, 'no_password' ); + } elsif ($comment->user->phone_verified) { + $c->authenticate( { phone => $comment->user->phone, phone_verified => 1 }, 'no_password' ); + } else { + warn "Reached user authentication with no username verification"; + } + $c->set_session_cookie_expire(0); + + $c->stash->{update}->confirm; + $c->stash->{update}->update; + $c->forward('update_problem'); + $c->stash->{add_alert} = $data->{add_alert}; + $c->forward('signup_for_alerts'); return 1; } diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index 8f8205719..ec7a192b3 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -2,9 +2,9 @@ package FixMyStreet::App::Controller::Reports; use Moose; use namespace::autoclean; -use File::Slurp; use JSON::MaybeXS; use List::MoreUtils qw(any); +use Path::Tiny; use POSIX qw(strcoll); use RABX; use mySociety::MaPit; @@ -69,34 +69,16 @@ sub index : Path : Args(0) { } } - # Fetch all bodies - my @bodies = $c->model('DB::Body')->search({ - deleted => 0, - }, { - '+select' => [ { count => 'area_id' } ], - '+as' => [ 'area_count' ], - join => 'body_areas', - distinct => 1, - })->all; - @bodies = sort { strcoll($a->name, $b->name) } @bodies; - $c->stash->{bodies} = \@bodies; - $c->stash->{any_empty_bodies} = any { $_->get_column('area_count') == 0 } @bodies; + my $dashboard = $c->forward('load_dashboard_data'); - my $dashboard = eval { - my $data = File::Slurp::read_file( - FixMyStreet->path_to( '../data/all-reports-dashboard.json' )->stringify - ); - $c->stash(decode_json($data)); - return 1; - }; - my $table = eval { - my $data = File::Slurp::read_file( - FixMyStreet->path_to( '../data/all-reports.json' )->stringify - ); + my $table = !$c->stash->{body} && eval { + my $data = path(FixMyStreet->path_to('../data/all-reports.json'))->slurp_utf8; $c->stash(decode_json($data)); return 1; }; if (!$dashboard && !$table) { + $c->detach('/page_error_404_not_found') if $c->stash->{body}; + my $message = _("There was a problem showing the All Reports page. Please try again later."); if ($c->config->{STAGING_SITE}) { $message .= '</p><p>Perhaps the bin/update-all-reports script needs running. Use: bin/update-all-reports</p><p>' @@ -105,6 +87,26 @@ sub index : Path : Args(0) { $c->detach('/page_error_500_internal_error', [ $message ]); } + if ($c->stash->{body}) { + my $children = $c->stash->{body}->first_area_children; + unless ($children->{error}) { + $c->stash->{children} = $children; + } + } else { + # Fetch all bodies + my @bodies = $c->model('DB::Body')->search({ + deleted => 0, + }, { + '+select' => [ { count => 'area_id' } ], + '+as' => [ 'area_count' ], + join => 'body_areas', + distinct => 1, + })->all; + @bodies = sort { strcoll($a->name, $b->name) } @bodies; + $c->stash->{bodies} = \@bodies; + $c->stash->{any_empty_bodies} = any { $_->get_column('area_count') == 0 } @bodies; + } + # Down here so that error pages aren't cached. $c->response->header('Cache-Control' => 'max-age=3600'); } @@ -131,9 +133,24 @@ sub ward : Path : Args(2) { $c->forward('/auth/get_csrf_token'); + my @wards = split /\|/, $ward || ""; $c->forward( 'body_check', [ $body ] ); - $c->forward( 'ward_check', [ $ward ] ) - if $ward; + + my $body_short = $c->cobrand->short_name( $c->stash->{body} ); + $c->stash->{body_url} = '/reports/' . $body_short; + + if ($ward && $ward eq 'summary') { + if (my $actual_ward = $c->get_param('ward')) { + $ward = $c->cobrand->short_name({ name => $actual_ward }); + $c->res->redirect($ward); + $c->detach; + } + $c->cobrand->call_hook('council_dashboard_hook'); + $c->go('index'); + } + + $c->forward( 'ward_check', [ @wards ] ) + if @wards; $c->forward( 'check_canonical_url', [ $body ] ); $c->forward( 'stash_report_filter_status' ); $c->forward( 'load_and_group_problems' ); @@ -142,13 +159,10 @@ sub ward : Path : Args(2) { $c->detach('ajax', [ 'reports/_problem-list.html' ]); } - my $body_short = $c->cobrand->short_name( $c->stash->{body} ); $c->stash->{rss_url} = '/rss/reports/' . $body_short; $c->stash->{rss_url} .= '/' . $c->cobrand->short_name( $c->stash->{ward} ) if $c->stash->{ward}; - $c->stash->{body_url} = '/reports/' . $body_short; - $c->stash->{stats} = $c->cobrand->get_report_stats(); my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, { @@ -166,7 +180,7 @@ sub ward : Path : Args(2) { my %map_params = ( latitude => @$pins ? $pins->[0]{latitude} : 0, longitude => @$pins ? $pins->[0]{longitude} : 0, - area => $c->stash->{ward} ? $c->stash->{ward}->{id} : [ keys %{$c->stash->{body}->areas} ], + area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ], any_zoom => 1, ); FixMyStreet::Map::display_map( @@ -176,10 +190,8 @@ sub ward : Path : Args(2) { $c->cobrand->tweak_all_reports_map( $c ); # List of wards - if ( !$c->stash->{ward} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) { - my $children = mySociety::MaPit::call('area/children', [ $c->stash->{body}->body_areas->first->area_id ], - type => $c->cobrand->area_types_children, - ); + 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} @@ -309,17 +321,35 @@ sub body_check : Private { # Oslo/ kommunes sharing a name in Norway return if $c->cobrand->reports_body_check( $c, $q_body ); + my $body = $c->forward('body_find', [ $q_body ]); + if ($body) { + $c->stash->{body} = $body; + return; + } + + # No result, bad body name. + $c->detach( 'redirect_index' ); +} + +=head2 + +Given a string, try and find a body starting with/matching that string. +Returns the matching body object if found. + +=cut + +sub body_find : Private { + my ($self, $c, $q_body) = @_; + # We must now have a string to check my @bodies = $c->model('DB::Body')->search( { name => { -like => "$q_body%" } } )->all; if (@bodies == 1) { - $c->stash->{body} = $bodies[0]; - return; + return $bodies[0]; } else { foreach (@bodies) { if (lc($_->name) eq lc($q_body) || $_->name =~ /^\Q$q_body\E (Borough|City|District|County) Council$/i) { - $c->stash->{body} = $_; - return; + return $_; } } } @@ -332,29 +362,27 @@ sub body_check : Private { if (@translations == 1) { if ( my $body = $c->model('DB::Body')->find( { id => $translations[0]->object_id } ) ) { - $c->stash->{body} = $body; - return; + return $body; } } - - # No result, bad body name. - $c->detach( 'redirect_index' ); } =head2 ward_check -This action checks the ward name from a URI exists and is part of the right +This action checks the ward names from a URI exists and are part of the right parent, already found with body_check. It either stores the ward Area if okay, or redirects to the body page if bad. =cut sub ward_check : Private { - my ( $self, $c, $ward ) = @_; + my ( $self, $c, @wards ) = @_; - $ward =~ s/\+/ /g; - $ward =~ s/\.html//; - $ward =~ s{_}{/}g; + foreach (@wards) { + s/\+/ /g; + s/\.html//; + s{_}{/}g; + } # Could be from RSS area, or body... my $parent_id; @@ -366,21 +394,125 @@ sub ward_check : Private { $parent_id = $c->stash->{area}->{id}; } - my $qw = mySociety::MaPit::call('areas', $ward, + my $qw = mySociety::MaPit::call('area/children', [ $parent_id ], type => $c->cobrand->area_types_children, ); + my %names = map { $_ => 1 } @wards; + my @areas; foreach my $area (sort { $a->{name} cmp $b->{name} } values %$qw) { - if ($area->{parent_area} == $parent_id) { - $c->stash->{ward} = $area; - return; - } + push @areas, $area if $names{$area->{name}}; + } + if (@areas) { + $c->stash->{ward} = $areas[0] if @areas == 1; + $c->stash->{wards} = \@areas; + return; } + # Given a false ward name $c->stash->{body} = $c->stash->{area} unless $c->stash->{body}; $c->detach( 'redirect_body' ); } +=head2 summary + +This is the summary page used on fixmystreet.com + +=cut + +sub summary : Private { + my ($self, $c) = @_; + my $dashboard = $c->forward('load_dashboard_data'); + + eval { + my $data = path(FixMyStreet->path_to('../data/all-reports-dashboard.json'))->slurp_utf8; + $data = decode_json($data); + $c->stash( + top_five_bodies => $data->{top_five_bodies}, + average => $data->{average}, + ); + }; + + my $dtf = $c->model('DB')->storage->datetime_parser; + my $period = $c->stash->{period} = $c->get_param('period') || ''; + my $start_date; + if ($period eq 'ever') { + $start_date = DateTime->new(year => 2007); + } elsif ($period eq 'year') { + $start_date = DateTime->now->subtract(years => 1); + } elsif ($period eq '3months') { + $start_date = DateTime->now->subtract(months => 3); + } elsif ($period eq 'week') { + $start_date = DateTime->now->subtract(weeks => 1); + } else { + $c->stash->{period} = 'month'; + $start_date = DateTime->now->subtract(months => 1); + } + + # required to stop errors in generate_grouped_data + $c->stash->{q_state} = ''; + $c->stash->{ward} = $c->get_param('ward'); + $c->stash->{start_date} = $dtf->format_date($start_date); + $c->stash->{end_date} = $c->get_param('end_date'); + + $c->stash->{group_by_default} = 'category'; + + my $area_id = $c->stash->{body}->body_areas->first->area_id; + my $children = mySociety::MaPit::call('area/children', $area_id, + type => $c->cobrand->area_types_children, + ); + $c->stash->{children} = $children; + + $c->forward('/admin/fetch_contacts'); + $c->stash->{contacts} = [ $c->stash->{contacts}->all ]; + + $c->forward('/dashboard/construct_rs_filter'); + + if ( $c->get_param('csv') ) { + $c->detach('export_summary_csv'); + } + + $c->forward('/dashboard/generate_grouped_data'); + $c->forward('/dashboard/generate_body_response_time'); + + $c->stash->{template} = 'reports/summary.html'; +} + +sub export_summary_csv : Private { + my ( $self, $c ) = @_; + + $c->stash->{csv} = { + problems => $c->stash->{problems_rs}->search_rs({}, { + rows => 100, + order_by => { '-desc' => 'me.confirmed' }, + }), + headers => [ + 'Report ID', + 'Title', + 'Category', + 'Created', + 'Confirmed', + 'Status', + 'Latitude', 'Longitude', + 'Query', + 'Report URL', + ], + columns => [ + 'id', + 'title', + 'category', + 'created_pp', + 'confirmed_pp', + 'state', + 'latitude', 'longitude', + 'postcode', + 'url', + ], + filename => 'fixmystreet-data.csv', + }; + $c->forward('/dashboard/generate_csv'); +} + =head2 check_canonical_url Given an already found (case-insensitively) body, check what URL @@ -397,6 +529,25 @@ sub check_canonical_url : Private { $c->detach( 'redirect_body' ) unless $body_short eq $url_short; } +sub load_dashboard_data : Private { + my ($self, $c) = @_; + my $dashboard = eval { + my $data = FixMyStreet->config('TEST_DASHBOARD_DATA'); + # uncoverable branch true + unless ($data) { + my $fn = '../data/all-reports-dashboard'; + if ($c->stash->{body}) { + $fn .= '-' . $c->stash->{body}->id; + } + $data = decode_json(path(FixMyStreet->path_to($fn . '.json'))->slurp_utf8); + } + $c->stash($data); + return 1; + }; + + return $dashboard; +} + sub load_and_group_problems : Private { my ( $self, $c ) = @_; @@ -448,8 +599,10 @@ sub load_and_group_problems : Private { my $problems = $c->cobrand->problems; - if ($c->stash->{ward}) { - $where->{areas} = { 'like', '%,' . $c->stash->{ward}->{id} . ',%' }; + if ($c->stash->{wards}) { + $where->{areas} = [ + map { { 'like', '%,' . $_->{id} . ',%' } } @{$c->stash->{wards}} + ]; $problems = $problems->to_body($c->stash->{body}); } elsif ($c->stash->{body}) { $problems = $problems->to_body($c->stash->{body}); @@ -510,8 +663,8 @@ sub redirect_body : Private { $url .= "/rss" if $c->stash->{rss}; $url .= '/reports'; $url .= '/' . $c->cobrand->short_name( $c->stash->{body} ); - $url .= '/' . $c->cobrand->short_name( $c->stash->{ward} ) - if $c->stash->{ward}; + $url .= '/' . join('|', map { $c->cobrand->short_name($_) } @{$c->stash->{wards}}) + if $c->stash->{wards}; $c->res->redirect( $c->uri_for($url, $c->req->params ) ); } @@ -529,16 +682,19 @@ sub stash_report_filter_status : Private { my $s = FixMyStreet::DB::Result::Problem->open_states(); %filter_problem_states = (%filter_problem_states, %$s); $filter_status{open} = 1; + $filter_status{$_} = 1 for keys %$s; } if ($status{closed}) { my $s = FixMyStreet::DB::Result::Problem->closed_states(); %filter_problem_states = (%filter_problem_states, %$s); $filter_status{closed} = 1; + $filter_status{$_} = 1 for keys %$s; } if ($status{fixed}) { my $s = FixMyStreet::DB::Result::Problem->fixed_states(); %filter_problem_states = (%filter_problem_states, %$s); $filter_status{fixed} = 1; + $filter_status{$_} = 1 for keys %$s; } if ($status{all}) { diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm index 3497ad0e1..7cf4783c0 100755 --- a/perllib/FixMyStreet/App/Controller/Rss.pm +++ b/perllib/FixMyStreet/App/Controller/Rss.pm @@ -3,7 +3,7 @@ package FixMyStreet::App::Controller::Rss; use Moose; use namespace::autoclean; use POSIX qw(strftime); -use HTML::Entities; +use HTML::Entities qw(); use URI::Escape; use XML::RSS; @@ -28,6 +28,10 @@ Catalyst Controller. =cut +sub encode_entities { + HTML::Entities::encode_entities($_[0], '\x00-\x1f\x7f<>&"\''); +} + sub updates : LocalRegex('^(\d+)$') { my ( $self, $c ) = @_; diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm index a1b0c57ba..bb6140e0a 100644 --- a/perllib/FixMyStreet/App/Controller/Tokens.pm +++ b/perllib/FixMyStreet/App/Controller/Tokens.pm @@ -45,10 +45,10 @@ sub confirm_problem : Path('/P') { # Load the problem my $data = $auth_token->data; $data = { id => $data } unless ref $data; + $c->stash->{token_data} = $data; - my $problem_id = $data->{id}; # Look at all problems, not just cobrand, in case am approving something we don't actually show - my $problem = $c->model('DB::Problem')->find( { id => $problem_id } ) + my $problem = $c->model('DB::Problem')->find( { id => $data->{id} } ) || $c->detach('token_error'); $c->stash->{report} = $problem; @@ -56,64 +56,7 @@ sub confirm_problem : Path('/P') { if $problem->state eq 'unconfirmed' && $auth_token->created < DateTime->now->subtract( months => 1 ); - # check that this email or domain are not the cause of abuse. If so hide it. - if ( $problem->is_from_abuser ) { - $problem->update( - { state => 'hidden', lastupdate => \'current_timestamp' } ); - $c->stash->{template} = 'tokens/abuse.html'; - return; - } - - # For Zurich, email confirmation simply sets a flag, it does not change the - # problem state, log in, or anything else - if ($c->cobrand->moniker eq 'zurich') { - $problem->set_extra_metadata( email_confirmed => 1 ); - $problem->update( { - confirmed => \'current_timestamp', - } ); - - if ( $data->{name} || $data->{password} ) { - $problem->user->name( $data->{name} ) if $data->{name}; - $problem->user->phone( $data->{phone} ) if $data->{phone}; - $problem->user->update; - } - - return 1; - } - - if ($problem->state ne 'unconfirmed') { - my $report_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url; - $c->res->redirect($report_uri); - return; - } - - # We have an unconfirmed problem - $problem->update( - { - state => 'confirmed', - confirmed => \'current_timestamp', - lastupdate => \'current_timestamp', - } - ); - - # Subscribe problem reporter to email updates - $c->forward( '/report/new/create_reporter_alert' ); - - # log the problem creation user in to the site - if ( $data->{name} || $data->{password} ) { - $problem->user->name( $data->{name} ) if $data->{name}; - $problem->user->phone( $data->{phone} ) if $data->{phone}; - $problem->user->password( $data->{password}, 1 ) if $data->{password}; - $problem->user->title( $data->{title} ) if $data->{title}; - $problem->user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id}; - $problem->user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id}; - $problem->user->update; - } - $c->authenticate( { email => $problem->user->email }, 'no_password' ); - $c->set_session_cookie_expire(0); - - $c->stash->{created_report} = 'fromemail'; - return 1; + $c->forward('/report/new/process_confirmation'); } =head2 redirect_to_partial_problem @@ -170,7 +113,7 @@ sub confirm_alert : Path('/A') { } if (!$alert->confirmed && $c->stash->{confirm_type} ne 'unsubscribe') { - $c->authenticate( { email => $alert->user->email }, 'no_password' ); + $c->authenticate( { email => $alert->user->email, email_verified => 1 }, 'no_password' ); $c->set_session_cookie_expire(0); } @@ -205,11 +148,9 @@ sub confirm_update : Path('/C') { $c->forward( 'load_auth_token', [ $token_code, 'comment' ] ); # Load the update - my $data = $auth_token->data; - my $comment_id = $data->{id}; - $c->stash->{add_alert} = $data->{add_alert}; + my $data = $c->stash->{token_data} = $auth_token->data; - my $comment = $c->model('DB::Comment')->find( { id => $comment_id } ) + my $comment = $c->model('DB::Comment')->find( { id => $data->{id} } ) || $c->detach('token_error'); $c->stash->{update} = $comment; @@ -217,32 +158,7 @@ sub confirm_update : Path('/C') { if $comment->state ne 'confirmed' && $auth_token->created < DateTime->now->subtract( months => 1 ); - # check that this email or domain are not the cause of abuse. If so hide it. - if ( $comment->is_from_abuser ) { - $c->stash->{template} = 'tokens/abuse.html'; - return; - } - - if ( $comment->state ne 'unconfirmed' ) { - my $report_uri = $c->cobrand->base_url_for_report( $comment->problem ) . $comment->problem->url; - $c->res->redirect($report_uri); - return; - } - - if ( $data->{name} || $data->{password} ) { - $comment->user->name( $data->{name} ) if $data->{name}; - $comment->user->password( $data->{password}, 1 ) if $data->{password}; - $comment->user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id}; - $comment->user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id}; - $comment->user->update; - } - - $c->authenticate( { email => $comment->user->email }, 'no_password' ); - $c->set_session_cookie_expire(0); - - $c->forward('/report/update/confirm'); - - return 1; + $c->forward('/report/update/process_confirmation'); } sub load_questionnaire : Private { @@ -269,7 +185,7 @@ sub questionnaire : Path('/Q') : Args(1) { my $questionnaire = $c->stash->{questionnaire}; if (!$questionnaire->whenanswered) { - $c->authenticate( { email => $questionnaire->problem->user->email }, 'no_password' ); + $c->authenticate( { email => $questionnaire->problem->user->email, email_verified => 1 }, 'no_password' ); $c->set_session_cookie_expire(0); } $c->forward( '/questionnaire/show' ); diff --git a/perllib/FixMyStreet/Cobrand/Angus.pm b/perllib/FixMyStreet/Cobrand/Angus.pm index 51a3da56a..056101574 100644 --- a/perllib/FixMyStreet/Cobrand/Angus.pm +++ b/perllib/FixMyStreet/Cobrand/Angus.pm @@ -24,6 +24,8 @@ sub example_places { return ( 'DD8 3AP', "Canmore Street" ); } +sub map_type { 'Angus' } + sub default_show_name { 0 } sub disambiguate_location { diff --git a/perllib/FixMyStreet/Cobrand/Bromley.pm b/perllib/FixMyStreet/Cobrand/Bromley.pm index e7d5e186a..2f47225a7 100644 --- a/perllib/FixMyStreet/Cobrand/Bromley.pm +++ b/perllib/FixMyStreet/Cobrand/Bromley.pm @@ -59,10 +59,6 @@ sub map_type { 'Bromley'; } -sub on_map_default_max_pin_age { - return '1 month'; -} - # Bromley pins always yellow sub pin_colour { my ( $self, $p, $context ) = @_; diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index 250919d09..c33bda7f3 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -7,7 +7,6 @@ use FixMyStreet; use FixMyStreet::DB; use FixMyStreet::Geocode::Bing; use DateTime; -use Encode; use List::MoreUtils 'none'; use URI; use Digest::MD5 qw(md5_hex); @@ -438,22 +437,6 @@ sub reports_ordering { return 'updated-desc'; } -=head2 on_map_list_limit - -Return the maximum number of items to be given in the list of reports on the map - -=cut - -sub on_map_list_limit { return undef; } - -=head2 on_map_default_max_pin_age - -Return the default maximum age for pins. - -=cut - -sub on_map_default_max_pin_age { return '6 months'; } - =head2 on_map_default_status Return the default ?status= query parameter to use for filter on map page. @@ -896,9 +879,8 @@ sub _fallback_body_sender { }; sub example_places { - my $e = FixMyStreet->config('EXAMPLE_PLACES') || [ 'High Street', 'Main Street' ]; - $e = [ map { Encode::decode('UTF-8', $_) } @$e ]; - return $e; + # uncoverable branch true + FixMyStreet->config('EXAMPLE_PLACES') || [ 'High Street', 'Main Street' ]; } =head2 title_list @@ -1085,10 +1067,13 @@ sub state_groups_inspect { [ $rs->display('confirmed'), [ grep { $_ ne 'planned' } FixMyStreet::DB::Result::Problem->open_states ] ], @fixed ? [ $rs->display('fixed'), [ 'fixed - council' ] ] : (), [ $rs->display('closed'), [ grep { $_ ne 'closed' } FixMyStreet::DB::Result::Problem->closed_states ] ], - [ $rs->display('hidden'), [ 'hidden' ] ] ] } +sub max_detailed_info_length { 0 } + +sub prefill_report_fields_for_inspector { 0 } + =head2 never_confirm_updates If true then we never send an email to confirm an update @@ -1258,6 +1243,25 @@ admin. sub allow_report_extra_fields { 0 } +sub social_auth_enabled { + my $self = shift; + my $key_present = FixMyStreet->config('FACEBOOK_APP_ID') or FixMyStreet->config('TWITTER_KEY'); + return $key_present && !$self->call_hook("social_auth_disabled"); +} + + +=head2 send_moderation_notifications + +Used to control whether an email is sent to the problem reporter when a report +is moderated. + +Note that this is called in the context of the cobrand used to perform the +moderation, so e.g. if a UK council cobrand disables the moderation +notifications and a report is moderated on fixmystreet.com, the email will +still be sent (because it wasn't disabled on the FixMyStreet cobrand). + +=cut +sub send_moderation_notifications { 1 } 1; diff --git a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm index c50721334..591234877 100644 --- a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm +++ b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm @@ -1,6 +1,9 @@ package FixMyStreet::Cobrand::FixMyStreet; use base 'FixMyStreet::Cobrand::UK'; +use strict; +use warnings; + use mySociety::Random; use constant COUNCIL_ID_BROMLEY => 2482; @@ -52,15 +55,109 @@ sub extra_contact_validation { $c->stash->{dest} = $c->get_param('dest'); - $errors{dest} = "Please enter who your message is for" - unless $c->get_param('dest'); - - if ( $c->get_param('dest') eq 'council' || $c->get_param('dest') eq 'update' ) { + if (!$c->get_param('dest')) { + $errors{dest} = "Please enter who your message is for"; + } elsif ( $c->get_param('dest') eq 'council' || $c->get_param('dest') eq 'update' ) { $errors{not_for_us} = 1; } return %errors; } -1; +=head2 council_dashboard_hook + +This is for council-specific dashboard pages, which can only be seen by +superusers and logged-in users with an email domain matching a body name. +=cut + +sub council_dashboard_hook { + my $self = shift; + my $c = $self->{c}; + + unless ( $c->user_exists ) { + $c->res->redirect('/about/council-dashboard'); + $c->detach; + } + + $c->forward('/admin/fetch_contacts'); + + $c->detach('/reports/summary') if $c->user->is_superuser; + + my $body = $c->user->from_body || _user_to_body($c); + if ($body) { + # Matching URL and user's email body + $c->detach('/reports/summary') if $body->id eq $c->stash->{body}->id; + + # Matched /a/ body, redirect to its summary page + $c->stash->{body} = $body; + $c->stash->{wards} = [ { name => 'summary' } ]; + $c->detach('/reports/redirect_body'); + } + + $c->res->redirect('/about/council-dashboard'); +} + +sub _user_to_body { + my $c = shift; + my $email = lc $c->user->email; + return _email_to_body($c, $email); +} + +sub _email_to_body { + my ($c, $email) = @_; + my ($domain) = $email =~ m{ @ (.*) \z }x; + + my @data = eval { FixMyStreet->path_to('../data/fixmystreet-councils.csv')->slurp }; + my $body; + foreach (@data) { + chomp; + my ($d, $b) = split /\|/; + if ($d eq $domain) { + $body = $b; + last; + } + } + # If we didn't find a lookup entry, default to the first part of the domain + unless ($body) { + $domain =~ s/\.gov\.uk$//; + $body = ucfirst $domain; + } + + $body = $c->forward('/reports/body_find', [ $body ]); + return $body; +} + +sub about_hook { + my $self = shift; + my $c = $self->{c}; + + if ($c->stash->{template} eq 'about/council-dashboard.html') { + $c->stash->{form_name} = $c->get_param('name') || ''; + $c->stash->{email} = $c->get_param('username') || ''; + if ($c->user_exists) { + my $body = _user_to_body($c); + if ($body) { + $c->stash->{body} = $body; + $c->stash->{wards} = [ { name => 'summary' } ]; + $c->detach('/reports/redirect_body'); + } + } + if (my $email = $c->get_param('username')) { + $email = lc $email; + $email =~ s/\s+//g; + my $body = _email_to_body($c, $email); + if ($body) { + # Send confirmation email (hopefully) + $c->stash->{template} = 'auth/general.html'; + $c->detach('/auth/general'); + } else { + $c->stash->{no_body_found} = 1; + $c->set_param('em', $email); # What the contact form wants + $c->detach('/contact/submit'); + } + } + } +} + +1; diff --git a/perllib/FixMyStreet/Cobrand/Greenwich.pm b/perllib/FixMyStreet/Cobrand/Greenwich.pm index ce4fae381..6ff30e83d 100644 --- a/perllib/FixMyStreet/Cobrand/Greenwich.pm +++ b/perllib/FixMyStreet/Cobrand/Greenwich.pm @@ -57,10 +57,6 @@ sub contact_email { sub reports_per_page { return 20; } -sub on_map_default_max_pin_age { - return '21 days'; -} - sub open311_config { my ($self, $row, $h, $params) = @_; diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm index 44747a16f..23324e763 100644 --- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm +++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm @@ -217,6 +217,8 @@ sub user_extra_fields { sub display_days_ago_threshold { 28 } +sub max_detailed_info_length { 164 } + sub defect_type_extra_fields { return [ 'activity_code', diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index b82e170b6..f958b525a 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -220,4 +220,8 @@ sub available_permissions { return $perms; } +sub prefill_report_fields_for_inspector { 1 } + +sub social_auth_disabled { 1 } + 1; diff --git a/perllib/FixMyStreet/Cobrand/Warwickshire.pm b/perllib/FixMyStreet/Cobrand/Warwickshire.pm index 5fa967c62..73f66f3da 100644 --- a/perllib/FixMyStreet/Cobrand/Warwickshire.pm +++ b/perllib/FixMyStreet/Cobrand/Warwickshire.pm @@ -32,4 +32,6 @@ sub contact_email { } sub contact_name { 'Warwickshire County Council (do not reply)'; } +sub send_questionnaires { 0 } + 1; diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index de4a5262a..4dc95b178 100644 --- a/perllib/FixMyStreet/Cobrand/Zurich.pm +++ b/perllib/FixMyStreet/Cobrand/Zurich.pm @@ -994,7 +994,6 @@ sub _admin_send_email { my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL'); my $sender_name = $c->cobrand->contact_name; - utf8::decode($sender_name) unless utf8::is_utf8($sender_name); $c->send_email( $template, { to => [ $to ], diff --git a/perllib/FixMyStreet/DB/Factories.pm b/perllib/FixMyStreet/DB/Factories.pm index ec4dd630a..7b8234aec 100644 --- a/perllib/FixMyStreet/DB/Factories.pm +++ b/perllib/FixMyStreet/DB/Factories.pm @@ -118,7 +118,7 @@ use parent "DBIx::Class::Factory"; __PACKAGE__->resultset(FixMyStreet::DB->resultset("ResponsePriority")); __PACKAGE__->fields({ - name => __PACKAGE__->seq(sub { 'Priority #' . (shift()+1) }), + name => __PACKAGE__->seq(sub { 'Priority ' . (shift()+1) }), description => __PACKAGE__->seq(sub { 'Description #' . (shift()+1) }), }); diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm index 6481d5cfc..e5cd2b907 100644 --- a/perllib/FixMyStreet/DB/Result/Body.pm +++ b/perllib/FixMyStreet/DB/Result/Body.pm @@ -156,12 +156,13 @@ sub areas { } sub first_area_children { - my ( $self, $c ) = @_; + my ( $self ) = @_; my $area_id = $self->body_areas->first->area_id; + my $cobrand = $self->result_source->schema->cobrand; my $children = mySociety::MaPit::call('area/children', $area_id, - type => $c->cobrand->area_types_children, + type => $cobrand->area_types_children, ); return $children; @@ -182,4 +183,33 @@ sub get_cobrand_handler { return FixMyStreet::Cobrand->body_handler($self->areas); } +sub calculate_average { + my ($self) = @_; + + my $substmt = "select min(id) from comment where me.problem_id=comment.problem_id and (problem_state in ('fixed', 'fixed - council', 'fixed - user') or mark_fixed)"; + my $subquery = FixMyStreet::DB->resultset('Comment')->to_body($self)->search({ + -or => [ + problem_state => [ FixMyStreet::DB::Result::Problem->fixed_states() ], + mark_fixed => 1, + ], + 'me.id' => \"= ($substmt)", + 'me.state' => 'confirmed', + }, { + select => [ + { extract => "epoch from me.confirmed-problem.confirmed", -as => 'time' }, + ], + as => [ qw/time/ ], + rows => 100, + order_by => { -desc => 'me.confirmed' }, + join => 'problem' + })->as_subselect_rs; + + my $avg = $subquery->search({ + }, { + select => [ { avg => "time" } ], + as => [ qw/avg/ ], + })->first->get_column('avg'); + return $avg; +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm index 562f29693..60fd31510 100644 --- a/perllib/FixMyStreet/DB/Result/Comment.pm +++ b/perllib/FixMyStreet/DB/Result/Comment.pm @@ -229,13 +229,24 @@ sub meta_line { if ($self->anonymous or !$self->name) { $meta = sprintf( _( 'Posted anonymously at %s' ), Utils::prettify_dt( $self->confirmed ) ) - } elsif ($self->user->from_body) { + } elsif ($self->user->from_body || $self->get_extra_metadata('is_body_user') || $self->get_extra_metadata('is_superuser') ) { my $user_name = FixMyStreet::Template::html_filter($self->user->name); - my $body = $self->user->body; - if ($body eq 'Bromley Council') { - $body = "$body <img src='/cobrands/bromley/favicon.png' alt=''>"; - } elsif ($body eq 'Royal Borough of Greenwich') { - $body = "$body <img src='/cobrands/greenwich/favicon.png' alt=''>"; + my $body; + if ($self->get_extra_metadata('is_superuser')) { + $body = _('an administrator'); + } else { + # use this meta data in preference to the user's from_body setting + # in case they are no longer with the body, or have changed body. + if (my $body_id = $self->get_extra_metadata('is_body_user')) { + $body = FixMyStreet::App->model('DB::Body')->find({id => $body_id})->name; + } else { + $body = $self->user->body; + } + if ($body eq 'Bromley Council') { + $body = "$body <img src='/cobrands/bromley/favicon.png' alt=''>"; + } elsif ($body eq 'Royal Borough of Greenwich') { + $body = "$body <img src='/cobrands/greenwich/favicon.png' alt=''>"; + } } my $can_view_contribute = $c->user_exists && $c->user->has_permission_to('view_body_contribute_details', $self->problem->bodies_str_ids); if ($self->text) { @@ -274,14 +285,9 @@ sub problem_state_display { return FixMyStreet::DB->resultset("State")->display('confirmed', 1); } elsif ($self->problem_state) { my $state = $self->problem_state; - if ($state eq 'not responsible') { - $update_state = _( "not the council's responsibility" ); - if ($cobrand eq 'bromley' || $self->problem->to_body_named('Bromley')) { - $update_state = 'third party responsibility'; - } - } else { - $update_state = FixMyStreet::DB->resultset("State")->display($state, 1); - } + my $cobrand_name = $cobrand; + $cobrand_name = 'bromley' if $self->problem->to_body_named('Bromley'); + $update_state = FixMyStreet::DB->resultset("State")->display($state, 1, $cobrand_name); } return $update_state; diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm index 3b622b561..8625bf17a 100644 --- a/perllib/FixMyStreet/DB/Result/Problem.pm +++ b/perllib/FixMyStreet/DB/Result/Problem.pm @@ -485,12 +485,21 @@ Return a url for this problem report that logs a user in sub tokenised_url { my ($self, $user, $params) = @_; + my %params; + if ($user->email_verified) { + $params{email} = $user->email; + } elsif ($user->phone_verified) { + $params{phone} = $user->phone; + # This is so the email token can look up/ log in a phone user + $params{login_type} = 'phone'; + } + my $token = FixMyStreet::App->model('DB::Token')->create( { scope => 'email_sign_in', data => { + %params, id => $self->id, - email => $user->email, r => $self->url, p => $params, } @@ -618,6 +627,15 @@ sub meta_line { return $meta; } +sub nearest_address { + my $self = shift; + + return '' unless $self->geocode; + + my $address = $self->geocode->{resourceSets}[0]{resources}[0]; + return $address->{name}; +} + sub body { my ( $problem, $c ) = @_; my $body; @@ -849,10 +867,23 @@ sub update_send_failed { } ); } +sub add_send_method { + my $self = shift; + my $sender = shift; + ($sender = ref $sender) =~ s/^.*:://; + if (my $send_method = $self->send_method_used) { + $self->send_method_used("$send_method,$sender"); + } else { + $self->send_method_used($sender); + } +} + sub as_hashref { my $self = shift; my $c = shift; + my $state_t = FixMyStreet::DB->resultset("State")->display($self->state); + return { id => $self->id, title => $self->title, @@ -863,12 +894,16 @@ sub as_hashref { postcode => $self->postcode, areas => $self->areas, state => $self->state, - state_t => _( $self->state ), + state_t => $state_t, used_map => $self->used_map, is_fixed => $self->fixed_states->{ $self->state } ? 1 : 0, photos => [ map { $_->{url} } @{$self->photos} ], meta => $self->confirmed ? $self->meta_line( $c ) : '', - confirmed_pp => $self->confirmed ? $c->cobrand->prettify_dt( $self->confirmed ): '', + ($self->confirmed ? ( + confirmed => $self->confirmed, + confirmed_pp => $c->cobrand->prettify_dt( $self->confirmed ), + ) : ()), + created => $self->created, created_pp => $c->cobrand->prettify_dt( $self->created ), }; } @@ -896,16 +931,20 @@ sub photos { # if LOGIN_REQUIRED is set. To stop this happening, Varnish should be # configured to not strip cookies if the cookie_passthrough param is # present, which this line ensures will be if LOGIN_REQUIRED is set. - my $extra = (FixMyStreet->config('LOGIN_REQUIRED')) ? "&cookie_passthrough=1" : ""; + my $extra = ''; + if (FixMyStreet->config('LOGIN_REQUIRED')) { + $cachebust .= '&cookie_passthrough=1'; + $extra = '?cookie_passthrough=1'; + } my ($hash, $format) = split /\./, $_; { id => $hash, url_temp => "/photo/temp.$hash.$format$extra", url_temp_full => "/photo/fulltemp.$hash.$format$extra", - url => "/photo/$id.$i.$format?$cachebust$extra", - url_full => "/photo/$id.$i.full.$format?$cachebust$extra", - url_tn => "/photo/$id.$i.tn.$format?$cachebust$extra", - url_fp => "/photo/$id.$i.fp.$format?$cachebust$extra", + url => "/photo/$id.$i.$format?$cachebust", + url_full => "/photo/$id.$i.full.$format?$cachebust", + url_tn => "/photo/$id.$i.tn.$format?$cachebust", + url_fp => "/photo/$id.$i.fp.$format?$cachebust", idx => $i++, } } $photoset->all_ids; diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index 19adf5d49..d02039ac3 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -19,7 +19,7 @@ __PACKAGE__->add_columns( sequence => "users_id_seq", }, "email", - { data_type => "text", is_nullable => 0 }, + { data_type => "text", is_nullable => 1 }, "name", { data_type => "text", is_nullable => 1 }, "phone", @@ -30,21 +30,24 @@ __PACKAGE__->add_columns( { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "flagged", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "is_superuser", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "title", { data_type => "text", is_nullable => 1 }, "twitter_id", { data_type => "bigint", is_nullable => 1 }, "facebook_id", { data_type => "bigint", is_nullable => 1 }, - "is_superuser", - { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "area_id", { data_type => "integer", is_nullable => 1 }, "extra", { data_type => "text", is_nullable => 1 }, + "email_verified", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "phone_verified", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, ); __PACKAGE__->set_primary_key("id"); -__PACKAGE__->add_unique_constraint("users_email_key", ["email"]); __PACKAGE__->add_unique_constraint("users_facebook_id_key", ["facebook_id"]); __PACKAGE__->add_unique_constraint("users_twitter_id_key", ["twitter_id"]); __PACKAGE__->has_many( @@ -102,13 +105,19 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-09-16 14:22:10 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:7wfF1VnZax2QTXCIPXr+vg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-09-19 18:02:17 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:OKHKCSahWD3Ov6ulj+2f/w + +# These are not fully unique constraints (they only are when the *_verified +# is true), but this is managed in ResultSet::User's find() wrapper. +__PACKAGE__->add_unique_constraint("users_email_verified_key", ["email", "email_verified"]); +__PACKAGE__->add_unique_constraint("users_phone_verified_key", ["phone", "phone_verified"]); __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); use Moo; +use FixMyStreet::SMS; use mySociety::EmailUtil; use namespace::clean -except => [ 'meta' ]; @@ -125,6 +134,26 @@ __PACKAGE__->add_columns( }, ); +=head2 username + +Returns a verified email or phone for this user, preferring email, +or undef if neither verified (shouldn't happen). + +=cut + +sub username { + my $self = shift; + return $self->email if $self->email_verified; + return $self->phone_display if $self->phone_verified; +} + +sub phone_display { + my $self = shift; + return $self->phone unless $self->phone; + my $parsed = FixMyStreet::SMS->parse_username($self->phone); + return $parsed->{phone} ? $parsed->{phone}->format : $self->phone; +} + sub latest_anonymity { my $self = shift; my $p = $self->problems->search(undef, { order_by => { -desc => 'id' } } )->first; @@ -157,11 +186,19 @@ sub check_for_errors { $errors{name} = _('Please enter your name'); } - if ( $self->email !~ /\S/ ) { - $errors{email} = _('Please enter your email'); - } - elsif ( !mySociety::EmailUtil::is_valid_email( $self->email ) ) { - $errors{email} = _('Please enter a valid email'); + if ($self->email_verified) { + if ($self->email !~ /\S/) { + $errors{username} = _('Please enter your email'); + } elsif (!mySociety::EmailUtil::is_valid_email($self->email)) { + $errors{username} = _('Please enter a valid email'); + } + } elsif ($self->phone_verified) { + my $parsed = FixMyStreet::SMS->parse_username($self->phone); + if (!$parsed->{phone}) { + $errors{username} = _('Please check your phone number is correct'); + } elsif (!$parsed->{may_be_mobile}) { + $errors{username} = _('Please enter a mobile number'); + } } return \%errors; diff --git a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm index 8b8951007..6e5e0220f 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm @@ -10,7 +10,7 @@ sub to_body { } sub nearby { - my ( $rs, $c, $dist, $ids, $limit, $mid_lat, $mid_lon, $interval, $categories, $states ) = @_; + my ( $rs, $c, $dist, $ids, $limit, $mid_lat, $mid_lon, $categories, $states ) = @_; unless ( $states ) { $states = FixMyStreet::DB::Result::Problem->visible_states(); @@ -20,8 +20,6 @@ sub nearby { non_public => 0, state => [ keys %$states ], }; - $params->{'current_timestamp-lastupdate'} = { '<', \"'$interval'::interval" } - if $interval; $params->{id} = { -not_in => $ids } if $ids; $params->{category} = $categories if $categories && @$categories; diff --git a/perllib/FixMyStreet/DB/ResultSet/Problem.pm b/perllib/FixMyStreet/DB/ResultSet/Problem.pm index f1ed50721..ae45351c4 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Problem.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Problem.pm @@ -140,11 +140,11 @@ sub _recent { # Problems around a location sub around_map { - my ( $rs, $limit, %p) = @_; + my ( $rs, $c, %p) = @_; my $attr = { order_by => $p{order}, }; - $attr->{rows} = $limit if $limit; + $attr->{rows} = $c->cobrand->reports_per_page; unless ( $p{states} ) { $p{states} = FixMyStreet::DB::Result::Problem->visible_states(); @@ -156,12 +156,12 @@ sub around_map { latitude => { '>=', $p{min_lat}, '<', $p{max_lat} }, longitude => { '>=', $p{min_lon}, '<', $p{max_lon} }, }; - $q->{'current_timestamp - lastupdate'} = { '<', \"'$p{interval}'::interval" } - if $p{interval}; $q->{category} = $p{categories} if $p{categories} && @{$p{categories}}; - my @problems = mySociety::Locale::in_gb_locale { $rs->search( $q, $attr )->include_comment_counts->all }; - return \@problems; + my $problems = mySociety::Locale::in_gb_locale { + $rs->search( $q, $attr )->include_comment_counts->page($p{page}); + }; + return $problems; } # Admin functions diff --git a/perllib/FixMyStreet/DB/ResultSet/State.pm b/perllib/FixMyStreet/DB/ResultSet/State.pm index ac13ec2a4..3e6169aeb 100644 --- a/perllib/FixMyStreet/DB/ResultSet/State.pm +++ b/perllib/FixMyStreet/DB/ResultSet/State.pm @@ -24,7 +24,10 @@ sub states { my $rs = shift; my $states = Memcached::get('states'); - if ($states) { + # If tests are run in parallel, the cached state in Memcached could be + # corrupted by multiple tests changing it at the same time + # uncoverable branch true + if ($states && !FixMyStreet->test_mode) { # Need to reattach schema $states->[0]->result_source->schema( $rs->result_source->schema ) if $states->[0]; return $states; @@ -55,7 +58,7 @@ sub fixed { [ $_[0]->_filter(sub { $_->type eq 'fixed' }) ] } # This function can be used to return that label's display name. sub display { - my ($rs, $label, $single_fixed) = @_; + my ($rs, $label, $single_fixed, $cobrand) = @_; my $unchanging = { unconfirmed => _("Unconfirmed"), hidden => _("Hidden"), @@ -69,6 +72,10 @@ sub display { }; $label = 'fixed' if $single_fixed && $label =~ /^fixed - (council|user)$/; return $unchanging->{$label} if $unchanging->{$label}; + if ($cobrand && $label eq 'not responsible') { + return 'third party responsibility' if $cobrand eq 'bromley'; + return _("not the council's responsibility"); + } my ($state) = $rs->_filter(sub { $_->label eq $label }); return $label unless $state; $state->name($translate_now->{$label}) if $translate_now->{$label}; diff --git a/perllib/FixMyStreet/DB/ResultSet/User.pm b/perllib/FixMyStreet/DB/ResultSet/User.pm index 7e657a936..9a8a50559 100644 --- a/perllib/FixMyStreet/DB/ResultSet/User.pm +++ b/perllib/FixMyStreet/DB/ResultSet/User.pm @@ -4,5 +4,40 @@ use base 'DBIx::Class::ResultSet'; use strict; use warnings; +use Moo; + +# The database has a partial unique index on email (when email_verified is +# true), and phone (when phone_verified is true). In the code, we can only +# say these are fully unique indices, which they aren't, as there could be +# multiple identical unverified phone numbers. +# +# We assume that any and all calls to find (also called using find_or_new, +# find_or_create, or update_or_new/create) are to look up verified entries +# only (it would make no sense to find() a non-unique entry). Therefore we +# help the code along by specifying the most appropriate key to use, given +# the data provided, and setting the appropriate verified boolean. + +around find => sub { + my ($orig, $self) = (shift, shift); + # If there's already a key, assume caller knows what they're doing + if (ref $_[0] eq 'HASH' && !$_[1]->{key}) { + if ($_[0]->{id}) { + $_[1]->{key} = 'primary'; + } elsif (exists $_[0]->{email} && exists $_[0]->{phone}) { + # If there's both email and phone, caller must also have specified + # a verified boolean so that we know what we're looking for + if (!$_[0]->{email_verified} && !$_[0]->{phone_verified}) { + die "Cannot perform a User find() with both email and phone and no verified"; + } + } elsif (exists $_[0]->{email}) { + $_[0]->{email_verified} = 1; + $_[1]->{key} = 'users_email_verified_key'; + } elsif (exists $_[0]->{phone}) { + $_[0]->{phone_verified} = 1; + $_[1]->{key} = 'users_phone_verified_key'; + } + } + $self->$orig(@_); +}; 1; diff --git a/perllib/FixMyStreet/Geocode.pm b/perllib/FixMyStreet/Geocode.pm index b5bb7249c..aeac0ab6d 100644 --- a/perllib/FixMyStreet/Geocode.pm +++ b/perllib/FixMyStreet/Geocode.pm @@ -73,18 +73,23 @@ sub cache { my $cache_file = $cache_dir->child(md5_hex($url)); my $js; if (-s $cache_file && -M $cache_file <= 7 && !FixMyStreet->config('STAGING_SITE')) { - $js = $cache_file->slurp; + # uncoverable statement + $js = $cache_file->slurp_utf8; } else { $url .= '&' . $args if $args; $ua->timeout(15); $js = LWP::Simple::get($url); - $js = encode_utf8($js) if utf8::is_utf8($js); - $cache_dir->mkpath; + # The returned data is not correctly decoded if the content type is + # e.g. application/json. Which all of our geocoders return. + # uncoverable branch false + $js = decode_utf8($js) if !utf8::is_utf8($js); if ($js && (!$re || $js !~ $re) && !FixMyStreet->config('STAGING_SITE')) { - $cache_file->spew($js); + $cache_dir->mkpath; # uncoverable statement + # uncoverable statement + $cache_file->spew_utf8($js); } } - $js = JSON->new->utf8->allow_nonref->decode($js) if $js; + $js = JSON->new->allow_nonref->decode($js) if $js; return $js; } diff --git a/perllib/FixMyStreet/Geocode/OSM.pm b/perllib/FixMyStreet/Geocode/OSM.pm index f165963d7..020be3c2a 100644 --- a/perllib/FixMyStreet/Geocode/OSM.pm +++ b/perllib/FixMyStreet/Geocode/OSM.pm @@ -41,7 +41,7 @@ sub string { if $params->{bounds}; $query_params{countrycodes} = $params->{country} if $params->{country}; - $url .= join('&', map { "$_=$query_params{$_}" } keys %query_params); + $url .= join('&', map { "$_=$query_params{$_}" } sort keys %query_params); my $js = FixMyStreet::Geocode::cache('osm', $url); if (!$js) { diff --git a/perllib/FixMyStreet/Integrations/ExorRDI.pm b/perllib/FixMyStreet/Integrations/ExorRDI.pm index 093688e47..dc865e1ad 100644 --- a/perllib/FixMyStreet/Integrations/ExorRDI.pm +++ b/perllib/FixMyStreet/Integrations/ExorRDI.pm @@ -46,6 +46,9 @@ sub construct { time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone ); + my $tmo = $cobrand->traffic_management_options; + my %tm_lookup = map { $tmo->[$_] => $_ + 1 } 0..$#$tmo; + my $missed_cutoff = $now - DateTime::Duration->new( hours => 24 ); my %params = ( -and => [ @@ -102,7 +105,7 @@ sub construct { my $i = 0; foreach my $inspector_id (keys %$inspectors) { my $inspections = $inspectors->{$inspector_id}; - my $initials = $inspector_initials->{$inspector_id}; + my $initials = $inspector_initials->{$inspector_id} || "XX"; my %body_by_activity_code; foreach my $report (@$inspections) { @@ -116,13 +119,17 @@ sub construct { $location .= " Nearest postcode: $closest_address->{postcode}{postcode}." if $closest_address->{postcode}; } - my $description = sprintf("%s %s", $report->external_id || "", $report->get_extra_metadata('detailed_information') || ""); + my $traffic_information = $report->get_extra_metadata('traffic_information') || 'none'; + my $description = sprintf("%s %s %s %s", + $report->external_id || "", + $initials, + 'TM' . ($tm_lookup{$traffic_information} || '0'), + $report->get_extra_metadata('detailed_information') || ""); + # Maximum length of 180 characters total + $description = substr($description, 0, 180); my $activity_code = $report->defect_type ? $report->defect_type->get_extra_metadata('activity_code') : 'MC'; - my $traffic_information = $report->get_extra_metadata('traffic_information') ? - 'TM ' . $report->get_extra_metadata('traffic_information') - : 'TM none'; $body_by_activity_code{$activity_code} ||= []; $csv->add_row($body_by_activity_code{$activity_code}, @@ -133,7 +140,7 @@ sub construct { $location, # defect location field, which we don't capture from inspectors $report->inspection_log_entry->whenedited->strftime("%H%M"), # defect time raised "","","","","","","", # empty fields - $traffic_information, + "TM $traffic_information", $description, # defect description ); @@ -169,7 +176,7 @@ sub construct { "G", # start of an area/sequence $link_id, # area/link id, fixed value for our purposes "","", # must be empty - $initials || "XX", # inspector initials + $initials, # inspector initials $self->inspection_date->strftime("%y%m%d"), # date of inspection yymmdd "1600", # time of inspection hhmm, set to static value for now "D", # inspection variant, should always be D diff --git a/perllib/FixMyStreet/Map.pm b/perllib/FixMyStreet/Map.pm index b6b618efb..91c198913 100644 --- a/perllib/FixMyStreet/Map.pm +++ b/perllib/FixMyStreet/Map.pm @@ -67,6 +67,10 @@ sub display_map { return $map_class->display_map(@_); } +sub map_javascript { + $map_class->map_javascript; +} + sub map_features { my ( $c, %p ) = @_; @@ -79,32 +83,32 @@ sub map_features { # use deltas that are roughly 500m in the UK - so we get a 1 sq km search box my $lat_delta = 0.00438; my $lon_delta = 0.00736; - $p{min_lon} = $p{longitude} - $lon_delta; - $p{min_lat} = $p{latitude} - $lat_delta; - $p{max_lon} = $p{longitude} + $lon_delta; - $p{max_lat} = $p{latitude} + $lat_delta; + $p{min_lon} = Utils::truncate_coordinate($p{longitude} - $lon_delta); + $p{min_lat} = Utils::truncate_coordinate($p{latitude} - $lat_delta); + $p{max_lon} = Utils::truncate_coordinate($p{longitude} + $lon_delta); + $p{max_lat} = Utils::truncate_coordinate($p{latitude} + $lat_delta); } else { - $p{longitude} = ($p{max_lon} + $p{min_lon} ) / 2; - $p{latitude} = ($p{max_lat} + $p{min_lat} ) / 2; + $p{longitude} = Utils::truncate_coordinate(($p{max_lon} + $p{min_lon} ) / 2); + $p{latitude} = Utils::truncate_coordinate(($p{max_lat} + $p{min_lat} ) / 2); } - # list of problems around map can be limited, but should show all pins - my $around_limit = $c->cobrand->on_map_list_limit || undef; - - my $on_map_all = $c->cobrand->problems_on_map->around_map( undef, %p ); - my $on_map_list = $around_limit - ? $c->cobrand->problems_on_map->around_map( $around_limit, %p ) - : $on_map_all; + $p{page} = $c->get_param('p') || 1; + my $on_map = $c->cobrand->problems_on_map->around_map( $c, %p ); + my $pager = $c->stash->{pager} = $on_map->pager; + $on_map = [ $on_map->all ]; my $dist = FixMyStreet::Gaze::get_radius_containing_population( $p{latitude}, $p{longitude} ); - my $limit = 20; - my @ids = map { $_->id } @$on_map_list; - my $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, \@ids, $limit, @p{"latitude", "longitude", "interval", "categories", "states"} - ); + my $nearby; + if (@$on_map < $pager->entries_per_page && $pager->current_page == 1) { + my $limit = 20; + my @ids = map { $_->id } @$on_map; + $nearby = $c->model('DB::Nearby')->nearby( + $c, $dist, \@ids, $limit, @p{"latitude", "longitude", "categories", "states"} + ); + } - return ( $on_map_all, $on_map_list, $nearby, $dist ); + return ( $on_map, $nearby, $dist ); } sub click_to_wgs84 { diff --git a/perllib/FixMyStreet/Map/Angus.pm b/perllib/FixMyStreet/Map/Angus.pm new file mode 100644 index 000000000..98f5373c1 --- /dev/null +++ b/perllib/FixMyStreet/Map/Angus.pm @@ -0,0 +1,18 @@ +# FixMyStreet:Map::Angus +# More JavaScript, for street assets + +package FixMyStreet::Map::Angus; +use base 'FixMyStreet::Map::FMS'; + +use strict; + +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.angus.js', + '/js/map-OpenLayers.js', + '/js/map-bing-ol.js', + '/js/map-fms.js', + '/cobrands/fixmystreet/assets.js', + '/cobrands/angus/js.js', +] } + +1; diff --git a/perllib/FixMyStreet/Map/Bing.pm b/perllib/FixMyStreet/Map/Bing.pm index 617823b45..68c9fea32 100644 --- a/perllib/FixMyStreet/Map/Bing.pm +++ b/perllib/FixMyStreet/Map/Bing.pm @@ -8,7 +8,11 @@ use strict; sub map_type { '' } -sub map_template { 'bing' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/js/map-OpenLayers.js', + '/js/map-bing-ol.js', +] } sub copyright { '' } @@ -26,7 +30,7 @@ sub get_quadkey { } sub map_tile_base { - '', "//ecn.%s.tiles.virtualearth.net/tiles/r%s.png?g=3467"; + '', "//ecn.%s.tiles.virtualearth.net/tiles/r%s.png?g=5941"; } sub map_tiles { diff --git a/perllib/FixMyStreet/Map/Bristol.pm b/perllib/FixMyStreet/Map/Bristol.pm index c2925ff8d..5d05fbd34 100644 --- a/perllib/FixMyStreet/Map/Bristol.pm +++ b/perllib/FixMyStreet/Map/Bristol.pm @@ -30,12 +30,8 @@ sub tile_parameters { dpi => 96, inches_per_unit => 39.3701, # BNG uses metres projection => 'EPSG:27700', - # The original tile origin values from the getCapabilities call are - # -5220400.0/4470200.0, but this results in the map tile being offset - # slightly. These corrected values were figured out manually by - # trial and error... - origin_x => -5220385.5, - origin_y => 4470189.0, + origin_x => -5220400.0, + origin_y => 4470200.0, }; return $params; } @@ -62,6 +58,15 @@ sub copyright { sub map_template { 'bristol' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.bristol.js', + '/js/map-OpenLayers.js', + '/js/map-wmts-base.js', + '/js/map-wmts-bristol.js', + '/cobrands/fixmystreet/assets.js', + '/cobrands/bristol/js.js', +] } + # Reproject a WGS84 lat/lon into BNG easting/northing sub reproject_from_latlon($$$) { my ($self, $lat, $lon) = @_; diff --git a/perllib/FixMyStreet/Map/Bromley.pm b/perllib/FixMyStreet/Map/Bromley.pm index 0cd36a2ac..1310c2a5a 100644 --- a/perllib/FixMyStreet/Map/Bromley.pm +++ b/perllib/FixMyStreet/Map/Bromley.pm @@ -9,7 +9,13 @@ use base 'FixMyStreet::Map::FMS'; use strict; -sub map_template { 'bromley' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/js/map-OpenLayers.js', + '/js/map-bing-ol.js', + '/js/map-fms.js', + '/cobrands/bromley/map.js', +] } sub map_tile_base { '-', "https://%sfix.bromley.gov.uk/tilma/%d/%d/%d.png"; diff --git a/perllib/FixMyStreet/Map/FMS.pm b/perllib/FixMyStreet/Map/FMS.pm index 50a21c125..13c7f9d87 100644 --- a/perllib/FixMyStreet/Map/FMS.pm +++ b/perllib/FixMyStreet/Map/FMS.pm @@ -11,6 +11,13 @@ use strict; sub map_template { 'fms' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/js/map-OpenLayers.js', + '/js/map-bing-ol.js', + '/js/map-fms.js', +] } + sub map_tile_base { '-', "//%stilma.mysociety.org/sv/%d/%d/%d.png"; } @@ -29,7 +36,7 @@ sub map_tiles { ]; } else { my $key = FixMyStreet->config('BING_MAPS_API_KEY'); - my $url = "g=3467"; + my $url = "g=5941"; $url .= "&productSet=mmOS&key=$key" if $z > 10 && !$ni; return [ "//ecn.t0.tiles.virtualearth.net/tiles/r" . $self->get_quadkey($x-1, $y-1, $z) . ".png?$url", diff --git a/perllib/FixMyStreet/Map/Google.pm b/perllib/FixMyStreet/Map/Google.pm index 8ddf4f4e9..f40eff167 100644 --- a/perllib/FixMyStreet/Map/Google.pm +++ b/perllib/FixMyStreet/Map/Google.pm @@ -13,6 +13,11 @@ use Utils; use constant ZOOM_LEVELS => 6; use constant MIN_ZOOM_LEVEL => 13; +sub map_javascript { [ + "http://maps.googleapis.com/maps/api/js?sensor=false", + '/js/map-google.js', +] } + # display_map C PARAMS # PARAMS include: # latitude, longitude for the centre point of the map diff --git a/perllib/FixMyStreet/Map/GoogleOL.pm b/perllib/FixMyStreet/Map/GoogleOL.pm index 55032d707..44d0e77e7 100644 --- a/perllib/FixMyStreet/Map/GoogleOL.pm +++ b/perllib/FixMyStreet/Map/GoogleOL.pm @@ -13,4 +13,16 @@ sub map_type { '' } sub map_template { 'google-ol' } +sub map_javascript { + my $google_maps_url = "https://maps.googleapis.com/maps/api/js?v=3"; + my $key = FixMyStreet->config('GOOGLE_MAPS_API_KEY'); + $google_maps_url .= "&key=$key" if $key; + [ + $google_maps_url, + '/vendor/OpenLayers/OpenLayers.google.js', + '/js/map-OpenLayers.js', + '/js/map-google-ol.js', + ] +} + 1; diff --git a/perllib/FixMyStreet/Map/OSM.pm b/perllib/FixMyStreet/Map/OSM.pm index 76af99d36..47d6eeee7 100644 --- a/perllib/FixMyStreet/Map/OSM.pm +++ b/perllib/FixMyStreet/Map/OSM.pm @@ -18,6 +18,12 @@ sub map_type { 'OpenLayers.Layer.OSM.Mapnik' } sub map_template { 'osm' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/js/map-OpenLayers.js', + '/js/map-OpenStreetMap.js', +] } + sub map_tiles { my ( $self, %params ) = @_; my ( $x, $y, $z ) = ( $params{x_tile}, $params{y_tile}, $params{zoom_act} ); diff --git a/perllib/FixMyStreet/Map/OSM/MapQuest.pm b/perllib/FixMyStreet/Map/OSM/MapQuest.pm index ac80e61b5..8b24e1ba2 100644 --- a/perllib/FixMyStreet/Map/OSM/MapQuest.pm +++ b/perllib/FixMyStreet/Map/OSM/MapQuest.pm @@ -11,8 +11,6 @@ use strict; sub map_type { 'OpenLayers.Layer.OSM.MapQuestOpen' } -sub map_template { 'mapquest-attribution' } - sub map_tiles { my ( $self, %params ) = @_; my ( $x, $y, $z ) = ( $params{x_tile}, $params{y_tile}, $params{zoom_act} ); diff --git a/perllib/FixMyStreet/Map/OSM/StreetView.pm b/perllib/FixMyStreet/Map/OSM/StreetView.pm index 12fbdb19d..3281faa35 100644 --- a/perllib/FixMyStreet/Map/OSM/StreetView.pm +++ b/perllib/FixMyStreet/Map/OSM/StreetView.pm @@ -11,7 +11,11 @@ use strict; sub map_type { '' } -sub map_template { 'osm-streetview' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + '/js/map-OpenLayers.js', + '/js/map-streetview.js', +] } sub base_tile_url { return 'os.openstreetmap.org/sv'; diff --git a/perllib/FixMyStreet/Map/OSM/TonerLite.pm b/perllib/FixMyStreet/Map/OSM/TonerLite.pm index b0d12c453..b50611f3d 100644 --- a/perllib/FixMyStreet/Map/OSM/TonerLite.pm +++ b/perllib/FixMyStreet/Map/OSM/TonerLite.pm @@ -16,7 +16,12 @@ use strict; sub map_type { 'OpenLayers.Layer.Stamen' } -sub map_template { 'osm-toner-lite' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.fixmystreet.js', + "https://stamen-maps.a.ssl.fastly.net/js/tile.stamen.js?v1.3.0", + '/js/map-OpenLayers.js', + '/js/map-toner-lite.js', +] } sub copyright { 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.' diff --git a/perllib/FixMyStreet/Map/Zurich.pm b/perllib/FixMyStreet/Map/Zurich.pm index ed68daeee..8b4a3d931 100644 --- a/perllib/FixMyStreet/Map/Zurich.pm +++ b/perllib/FixMyStreet/Map/Zurich.pm @@ -34,12 +34,8 @@ sub tile_parameters { dpi => 96, inches_per_unit => 39.3701, # BNG uses metres projection => 'EPSG:2056', - # The original tile origin values from the getCapabilities call are - # -27386400.0/31814500.0, but this results in the map tile being offset - # slightly. These corrected values were figured out manually by - # trial and error... - origin_x => -27386322.5, - origin_y => 31814423.0, + origin_x => -27386400.0, + origin_y => 31814500.0, }; return $params; } @@ -69,6 +65,13 @@ sub copyright { sub map_template { 'zurich' } +sub map_javascript { [ + '/vendor/OpenLayers/OpenLayers.zurich.js', + '/js/OpenLayers.Projection.CH1903Plus.js', + '/js/map-OpenLayers.js', + '/js/map-wmts-base.js', + '/js/map-wmts-zurich.js', +] } # Reproject a WGS84 lat/lon into Swiss easting/northing sub reproject_from_latlon($$$) { diff --git a/perllib/FixMyStreet/Roles/Abuser.pm b/perllib/FixMyStreet/Roles/Abuser.pm index fc76565ca..e2e9eb19e 100644 --- a/perllib/FixMyStreet/Roles/Abuser.pm +++ b/perllib/FixMyStreet/Roles/Abuser.pm @@ -13,9 +13,9 @@ Returns true if the user's email or its domain is listed in the 'abuse' table. sub is_from_abuser { my $self = shift; - # get the domain my $email = $self->user->email; - my ($domain) = $email =~ m{ @ (.*) \z }x; + my ($domain) = $email =~ m{ @ (.*) \z }x if $email; + my $phone = $self->user->phone; # search for an entry in the abuse table my $abuse_rs = $self->result_source->schema->resultset('Abuse'); @@ -23,6 +23,7 @@ sub is_from_abuser { return $abuse_rs->find( { email => $email } ) || $abuse_rs->find( { email => $domain } ) + || $abuse_rs->find( { email => $phone } ) || undef; } diff --git a/perllib/FixMyStreet/SMS.pm b/perllib/FixMyStreet/SMS.pm new file mode 100644 index 000000000..874108706 --- /dev/null +++ b/perllib/FixMyStreet/SMS.pm @@ -0,0 +1,118 @@ +package FixMyStreet::SMS; + +use strict; +use warnings; + +use JSON::MaybeXS; +use Moo; +use Number::Phone::Lib; +use WWW::Twilio::API; + +use FixMyStreet; +use mySociety::EmailUtil qw(is_valid_email); +use FixMyStreet::DB; + +has twilio => ( + is => 'lazy', + default => sub { + WWW::Twilio::API->new( + AccountSid => FixMyStreet->config('TWILIO_ACCOUNT_SID'), + AuthToken => FixMyStreet->config('TWILIO_AUTH_TOKEN'), + utf8 => 1, + ); + }, +); + +has from => ( + is => 'lazy', + default => sub { FixMyStreet->config('TWILIO_FROM_PARAMETER') }, +); + +has messaging_service => ( + is => 'lazy', + default => sub { FixMyStreet->config('TWILIO_MESSAGING_SERVICE_SID') }, +); + +sub send_token { + my ($class, $token_data, $token_scope, $to) = @_; + + # Random number between 10,000 and 75,535 + my $random = 10000 + unpack('n', mySociety::Random::random_bytes(2, 1)); + $token_data->{code} = $random; + my $token_obj = FixMyStreet::DB->resultset("Token")->create({ + scope => $token_scope, + data => $token_data, + }); + my $body = sprintf(_("Your verification code is %s"), $random); + + my $result = $class->new->send(to => $to, body => $body); + return { + random => $random, + token => $token_obj->token, + %$result, + }; +} + +sub send { + my ($self, %params) = @_; + my $output = $self->twilio->POST('Messages.json', + $self->from ? (From => $self->from) : (), + $self->messaging_service ? (MessagingServiceSid => $self->messaging_service) : (), + To => $params{to}, + Body => $params{body}, + ); + my $data = decode_json($output->{content}); + if ($output->{code} >= 400) { + return { error => "$data->{message} ($data->{code})" }; + } + return { success => $data->{sid} }; +} + +=head2 parse_username + +Given a string that might be an email address or a phone number, +return what we think it is, and if it's valid one of those. Or +undef if it's empty. + +=cut + +sub parse_username { + my ($class, $username) = @_; + + return { type => 'email', username => $username } unless $username; + + $username = lc $username; + $username =~ s/\s+//g; + + return { type => 'email', email => $username, username => $username } if is_valid_email($username); + + my $type = $username =~ /^[^a-z]+$/i ? 'phone' : 'email'; + my $phone = do { + if ($username =~ /^\+/) { + # If already in international format, use that + Number::Phone::Lib->new($username) + } else { + # Otherwise, assume it is country configured + my $country = FixMyStreet->config('PHONE_COUNTRY'); + Number::Phone::Lib->new($country, $username); + } + }; + + my $may_be_mobile = 0; + if ($phone) { + $type = 'phone'; + # Store phone without spaces + ($username = $phone->format) =~ s/\s+//g; + # Is this mobile definitely or possibly a mobile? (+1 numbers) + $may_be_mobile = 1 if $phone->is_mobile || (!defined $phone->is_mobile && $phone->is_geographic); + } + + return { + type => $type, + phone => $phone, + may_be_mobile => $may_be_mobile, + username => $username, + }; +} + +1; diff --git a/perllib/FixMyStreet/Script/Alerts.pm b/perllib/FixMyStreet/Script/Alerts.pm index c001cc311..4b5641f9e 100644 --- a/perllib/FixMyStreet/Script/Alerts.pm +++ b/perllib/FixMyStreet/Script/Alerts.pm @@ -39,6 +39,7 @@ sub send() { $item_table.name as item_name, $item_table.anonymous as item_anonymous, $item_table.confirmed as item_confirmed, $item_table.photo as item_photo, + $item_table.problem_state as item_problem_state, $head_table.* from alert, $item_table, $head_table where alert.parameter::integer = $head_table.id @@ -65,6 +66,7 @@ sub send() { $query = FixMyStreet::DB->schema->storage->dbh->prepare($query); $query->execute(); my $last_alert_id; + my $last_problem_state = ''; my %data = ( template => $alert_type->template, data => [], schema => $schema ); while (my $row = $query->fetchrow_hashref) { @@ -86,7 +88,26 @@ sub send() { alert_id => $row->{alert_id}, parameter => $row->{item_id}, } ); + + # this is currently only for new_updates + if (defined($row->{item_text})) { + # this might throw up the odd false positive but only in cases where the + # state has changed and there was already update text + if ($row->{item_problem_state} && + !( $last_problem_state eq '' && $row->{item_problem_state} eq 'confirmed' ) && + $last_problem_state ne $row->{item_problem_state} + ) { + my $state = FixMyStreet::DB->resultset("State")->display($row->{item_problem_state}, 1, $cobrand); + + my $update = _('State changed to:') . ' ' . $state; + $row->{item_text} = $row->{item_text} ? $row->{item_text} . "\n\n" . $update : + $update; + } + next unless $row->{item_text}; + } + if ($last_alert_id && $last_alert_id != $row->{alert_id}) { + $last_problem_state = ''; _send_aggregated_alert_email(%data); %data = ( template => $alert_type->template, data => [], schema => $schema ); } @@ -109,7 +130,7 @@ sub send() { my $user = $schema->resultset('User')->find( { id => $row->{alert_user_id} } ); - $data{alert_email} = $user->email; + $data{alert_user} = $user; my $token_obj = $schema->resultset('Token')->create( { scope => 'alert_to_reporter', data => { @@ -209,7 +230,7 @@ sub send() { template => $template, data => [], alert_id => $alert->id, - alert_email => $alert->user->email, + alert_user => $alert->user, lang => $alert->lang, cobrand => $cobrand, cobrand_data => $alert->cobrand_data, @@ -258,16 +279,20 @@ sub _send_aggregated_alert_email(%) { $cobrand->set_lang_and_domain( $data{lang}, 1, FixMyStreet->path_to('locale')->stringify ); FixMyStreet::Map::set_map_class($cobrand->map_type); - if (!$data{alert_email}) { + if (!$data{alert_user}) { my $user = $data{schema}->resultset('User')->find( { id => $data{alert_user_id} } ); - $data{alert_email} = $user->email; + $data{alert_user} = $user; } - my ($domain) = $data{alert_email} =~ m{ @ (.*) \z }x; + # Ignore phone-only users + return unless $data{alert_user}->email_verified; + + my $email = $data{alert_user}->email; + my ($domain) = $email =~ m{ @ (.*) \z }x; return if $data{schema}->resultset('Abuse')->search( { - email => [ $data{alert_email}, $domain ] + email => [ $email, $domain ] } )->first; my $token = $data{schema}->resultset("Token")->new_result( { @@ -275,7 +300,7 @@ sub _send_aggregated_alert_email(%) { data => { id => $data{alert_id}, type => 'unsubscribe', - email => $data{alert_email}, + email => $email, } } ); $data{unsubscribe_url} = $cobrand->base_url( $data{cobrand_data} ) . '/A/' . $token->token; @@ -286,7 +311,7 @@ sub _send_aggregated_alert_email(%) { "$data{template}.txt", \%data, { - To => $data{alert_email}, + To => $email, }, $sender, 0, diff --git a/perllib/FixMyStreet/Script/Questionnaires.pm b/perllib/FixMyStreet/Script/Questionnaires.pm index ec6139d2d..5fc01512d 100644 --- a/perllib/FixMyStreet/Script/Questionnaires.pm +++ b/perllib/FixMyStreet/Script/Questionnaires.pm @@ -49,7 +49,11 @@ sub send_questionnaires_period { # Not all cobrands send questionnaires next unless $cobrand->send_questionnaires; - next if $row->is_from_abuser; + + if ($row->is_from_abuser || !$row->user->email_verified) { + $row->update( { send_questionnaire => 0 } ); + next; + } # Cobranded and non-cobranded messages can share a database. In this case, the conf file # should specify a vhost to send the reports for each cobrand, so that they don't get sent diff --git a/perllib/FixMyStreet/Script/Reports.pm b/perllib/FixMyStreet/Script/Reports.pm index 1e5fd55bb..04ad1c893 100644 --- a/perllib/FixMyStreet/Script/Reports.pm +++ b/perllib/FixMyStreet/Script/Reports.pm @@ -84,7 +84,6 @@ sub send(;$) { $h{query} = $row->postcode; $h{url} = $email_base_url . $row->url; $h{admin_url} = $row->admin_url($cobrand); - $h{phone_line} = $h{phone} ? _('Phone:') . " $h{phone}\n\n" : ''; if ($row->photo) { $h{has_photo} = _("This web page also contains a photo of the problem, provided by the user.") . "\n\n"; $h{image_url} = $email_base_url . $row->photos->[0]->{url_full}; @@ -223,7 +222,9 @@ sub send(;$) { for my $sender ( keys %reporters ) { debug_print("sending using " . $sender, $row->id) if $debug_mode; $sender = $reporters{$sender}; - $result *= $sender->send( $row, \%h ); + my $res = $sender->send( $row, \%h ); + $result *= $res; + $row->add_send_method($sender) if !$res; if ( $sender->unconfirmed_counts) { foreach my $e (keys %{ $sender->unconfirmed_counts } ) { foreach my $c (keys %{ $sender->unconfirmed_counts->{$e} }) { @@ -299,6 +300,9 @@ sub _send_report_sent_email { my $nomail = shift; my $cobrand = shift; + # Don't send 'report sent' text + return unless $row->user->email_verified; + FixMyStreet::Email::send_cron( $row->result_source->schema, 'confirm_report_sent.txt', diff --git a/perllib/FixMyStreet/Script/UpdateAllReports.pm b/perllib/FixMyStreet/Script/UpdateAllReports.pm index 1bd069ee8..d6f3eb64b 100755 --- a/perllib/FixMyStreet/Script/UpdateAllReports.pm +++ b/perllib/FixMyStreet/Script/UpdateAllReports.pm @@ -4,11 +4,9 @@ use strict; use warnings; use FixMyStreet; +use FixMyStreet::Cobrand; use FixMyStreet::DB; -use File::Path (); -use File::Slurp; -use JSON::MaybeXS; use List::MoreUtils qw(zip); use List::Util qw(sum); @@ -21,6 +19,11 @@ if ( FixMyStreet->config('BASE_URL') =~ /zurich|zueri/ ) { $age_column = 'created'; } +my $dtf = FixMyStreet::DB->schema->storage->datetime_parser; + +my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker('default')->new; +FixMyStreet::DB->schema->cobrand($cobrand); + sub generate { my $include_areas = shift; @@ -81,13 +84,10 @@ sub generate { } } - my $body = encode_json( { + return { fixed => \%fixed, open => \%open, - } ); - - File::Path::mkpath( FixMyStreet->path_to( '../data/' )->stringify ); - File::Slurp::write_file( FixMyStreet->path_to( '../data/all-reports.json' )->stringify, \$body ); + }; } sub end_period { @@ -107,10 +107,18 @@ sub loop_period { } sub generate_dashboard { + my $body = shift; + my %data; + my $rs = FixMyStreet::DB->resultset('Problem'); + $rs = $rs->to_body($body) if $body; + + my $rs_c = FixMyStreet::DB->resultset('Comment'); + $rs_c = $rs_c->to_body($body) if $body; + my $end_today = end_period('day'); - my $min_confirmed = FixMyStreet::DB->resultset('Problem')->search({ + my $min_confirmed = $rs->search({ state => [ FixMyStreet::DB::Result::Problem->visible_states() ], }, { select => [ { min => 'confirmed' } ], @@ -134,11 +142,11 @@ sub generate_dashboard { my @problem_periods = loop_period($min_confirmed, $group_by, $extra); my %problems_reported_by_period = stuff_by_day_or_year( - $group_by, 'Problem', + $group_by, $rs, state => [ FixMyStreet::DB::Result::Problem->visible_states() ], ); my %problems_fixed_by_period = stuff_by_day_or_year( - $group_by, 'Problem', + $group_by, $rs, state => [ FixMyStreet::DB::Result::Problem->fixed_states() ], ); @@ -158,24 +166,23 @@ sub generate_dashboard { ); $data{last_seven_days} = \%last_seven_days; - my $dtf = FixMyStreet::DB->schema->storage->datetime_parser; my $eight_ago = $dtf->format_datetime(DateTime->now->subtract(days => 8)); %problems_reported_by_period = stuff_by_day_or_year('day', - 'Problem', + $rs, state => [ FixMyStreet::DB::Result::Problem->visible_states() ], - confirmed => { '>=', $eight_ago }, + 'me.confirmed' => { '>=', $eight_ago }, ); %problems_fixed_by_period = stuff_by_day_or_year('day', - 'Comment', - confirmed => { '>=', $eight_ago }, + $rs_c, + 'me.confirmed' => { '>=', $eight_ago }, -or => [ problem_state => [ FixMyStreet::DB::Result::Problem->fixed_states() ], mark_fixed => 1, ], ); my %problems_updated_by_period = stuff_by_day_or_year('day', - 'Comment', - confirmed => { '>=', $eight_ago }, + $rs_c, + 'me.confirmed' => { '>=', $eight_ago }, ); my $date = DateTime->today->subtract(days => 7); @@ -189,47 +196,17 @@ sub generate_dashboard { $last_seven_days{fixed_total} = sum @{$last_seven_days{fixed}}; $last_seven_days{updated_total} = sum @{$last_seven_days{updated}}; - my(@top_five_bodies); - $data{top_five_bodies} = \@top_five_bodies; - - my $bodies = FixMyStreet::DB->resultset('Body')->search; - my $substmt = "select min(id) from comment where me.problem_id=comment.problem_id and (problem_state in ('fixed', 'fixed - council', 'fixed - user') or mark_fixed)"; - while (my $body = $bodies->next) { - my $subquery = FixMyStreet::DB->resultset('Comment')->to_body($body)->search({ - -or => [ - problem_state => [ FixMyStreet::DB::Result::Problem->fixed_states() ], - mark_fixed => 1, - ], - 'me.id' => \"= ($substmt)", - 'me.state' => 'confirmed', - }, { - select => [ - { extract => "epoch from me.confirmed-problem.confirmed", -as => 'time' }, - ], - as => [ qw/time/ ], - rows => 100, - order_by => { -desc => 'me.confirmed' }, - join => 'problem' - })->as_subselect_rs; - my $avg = $subquery->search({ - }, { - select => [ { avg => "time" } ], - as => [ qw/avg/ ], - })->first->get_column('avg'); - push @top_five_bodies, { name => $body->name, days => int($avg / 60 / 60 / 24 + 0.5) } - if defined $avg; + if ($body) { + calculate_top_five_wards(\%data, $rs, $body); + } else { + calculate_top_five_bodies(\%data); } - @top_five_bodies = sort { $a->{days} <=> $b->{days} } @top_five_bodies; - $data{average} = @top_five_bodies - ? int((sum map { $_->{days} } @top_five_bodies) / @top_five_bodies + 0.5) : undef; - - @top_five_bodies = @top_five_bodies[0..4] if @top_five_bodies > 5; my $week_ago = $dtf->format_datetime(DateTime->now->subtract(days => 7)); - my $last_seven_days = FixMyStreet::DB->resultset("Problem")->search({ + my $last_seven_days = $rs->search({ confirmed => { '>=', $week_ago }, })->count; - my @top_five_categories = FixMyStreet::DB->resultset("Problem")->search({ + my @top_five_categories = $rs->search({ confirmed => { '>=', $week_ago }, category => { '!=', 'Other' }, }, { @@ -247,19 +224,17 @@ sub generate_dashboard { } $data{other_categories} = $last_seven_days; - my $body = encode_json( \%data ); - File::Path::mkpath( FixMyStreet->path_to( '../data/' )->stringify ); - File::Slurp::write_file( FixMyStreet->path_to( '../data/all-reports-dashboard.json' )->stringify, \$body ); + return \%data; } sub stuff_by_day_or_year { my $period = shift; - my $table = shift; + my $rs = shift; my %params = @_; - my $results = FixMyStreet::DB->resultset($table)->search({ + my $results = $rs->search({ %params }, { - select => [ { extract => \"$period from confirmed", -as => $period }, { count => 'id' } ], + select => [ { extract => \"$period from me.confirmed", -as => $period }, { count => 'me.id' } ], as => [ $period, 'count' ], group_by => [ $period ], }); @@ -271,4 +246,45 @@ sub stuff_by_day_or_year { return %out; } +sub calculate_top_five_bodies { + my ($data) = @_; + + my(@top_five_bodies); + + my $bodies = FixMyStreet::DB->resultset('Body')->search; + while (my $body = $bodies->next) { + my $avg = $body->calculate_average; + push @top_five_bodies, { name => $body->name, days => int($avg / 60 / 60 / 24 + 0.5) } + if defined $avg; + } + @top_five_bodies = sort { $a->{days} <=> $b->{days} } @top_five_bodies; + $data->{average} = @top_five_bodies + ? int((sum map { $_->{days} } @top_five_bodies) / @top_five_bodies + 0.5) : undef; + + @top_five_bodies = @top_five_bodies[0..4] if @top_five_bodies > 5; + $data->{top_five_bodies} = \@top_five_bodies; +} + +sub calculate_top_five_wards { + my ($data, $rs, $body) = @_; + + my $children = $body->first_area_children; + die $children->{error} if $children->{error}; + + my $week_ago = $dtf->format_datetime(DateTime->now->subtract(days => 7)); + my $last_seven_days = $rs->search({ confirmed => { '>=', $week_ago } }); + my $last_seven_days_count = $last_seven_days->count; + $last_seven_days = $last_seven_days->search(undef, { select => 'areas' }); + + while (my $row = $last_seven_days->next) { + $children->{$_}{reports}++ foreach grep { $children->{$_} } split /,/, $row->areas; + } + my @wards = sort { $b->{reports} <=> $a->{reports} } grep { $_->{reports} } values %$children; + @wards = @wards[0..4] if @wards > 5; + + my $sum_five = (sum map { $_->{reports} } @wards) || 0; + $data->{other_wards} = $last_seven_days_count - $sum_five; + $data->{wards} = \@wards; +} + 1; diff --git a/perllib/FixMyStreet/SendReport/Angus.pm b/perllib/FixMyStreet/SendReport/Angus.pm index b552fbd9d..4ba5f3070 100644 --- a/perllib/FixMyStreet/SendReport/Angus.pm +++ b/perllib/FixMyStreet/SendReport/Angus.pm @@ -154,7 +154,6 @@ sub send { my $external_id = $self->get_external_id( $result ); if ( $external_id ) { $row->external_id( $external_id ); - $row->send_method_used('Angus'); $return = 0; } } catch { diff --git a/perllib/FixMyStreet/SendReport/EastHants.pm b/perllib/FixMyStreet/SendReport/EastHants.pm index b24123f94..6baa8a306 100644 --- a/perllib/FixMyStreet/SendReport/EastHants.pm +++ b/perllib/FixMyStreet/SendReport/EastHants.pm @@ -6,7 +6,11 @@ BEGIN { extends 'FixMyStreet::SendReport'; } use Try::Tiny; use Encode; -use HTML::Entities; +use HTML::Entities qw(); + +sub encode_entities { + HTML::Entities::encode_entities($_[0], '\x00-\x1f\x7f<>&"\''); +} sub construct_message { my %h = @_; diff --git a/perllib/FixMyStreet/SendReport/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm index eefb14553..0aacc14a1 100644 --- a/perllib/FixMyStreet/SendReport/Email.pm +++ b/perllib/FixMyStreet/SendReport/Email.pm @@ -74,14 +74,21 @@ sub send { my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($row->cobrand)->new(); my $params = { To => $self->to, - From => $self->send_from( $row ), }; $cobrand->call_hook(munge_sendreport_params => $row, $h, $params); $params->{Bcc} = $self->bcc if @{$self->bcc}; - my $sender = FixMyStreet::Email::unique_verp_id('report', $row->id); + my $sender; + if ($row->user->email && $row->user->email_verified) { + $sender = FixMyStreet::Email::unique_verp_id('report', $row->id); + $params->{From} = $self->send_from( $row ); + } else { + $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL'); + my $name = sprintf(_("On behalf of %s"), $params->{From}[1]); + $params->{From} = [ $sender, $name ]; + } if (FixMyStreet::Email::test_dmarc($params->{From}[0]) || Utils::Email::same_domain($params->{From}, $params->{To})) { diff --git a/perllib/FixMyStreet/SendReport/Open311.pm b/perllib/FixMyStreet/SendReport/Open311.pm index eaa223bb2..ecda0bca1 100644 --- a/perllib/FixMyStreet/SendReport/Open311.pm +++ b/perllib/FixMyStreet/SendReport/Open311.pm @@ -49,7 +49,11 @@ sub send { } elsif ($_->{code} eq 'closest_address' && $h->{closest_address}) { push @$extra, { name => $_->{code}, value => $h->{$_->{code}} }; } elsif ($_->{code} =~ /^(easting|northing)$/) { - if ( $row->used_map || ( !$row->used_map && !$row->postcode ) ) { + # NB If there's ever a cobrand with always_send_latlong=0 and + # send_notpinpointed=0 then this line will need changing to + # consider the send_notpinpointed check, as per the + # '#NOTPINPOINTED#' code in perllib/Open311.pm. + if ( $row->used_map || $open311_params{always_send_latlong} ) { push @$extra, { name => $_->{code}, value => $h->{$_->{code}} }; } } @@ -80,7 +84,6 @@ sub send { if ( $resp ) { $row->external_id( $resp ); - $row->send_method_used('Open311'); $result *= 0; $self->success( 1 ); } else { diff --git a/perllib/FixMyStreet/TestAppProve.pm b/perllib/FixMyStreet/TestAppProve.pm index 7a387547d..d549b0148 100644 --- a/perllib/FixMyStreet/TestAppProve.pm +++ b/perllib/FixMyStreet/TestAppProve.pm @@ -86,8 +86,8 @@ sub run { $config->{FMS_DB_PASS} = ''; } - my $config_out = "general-test-autogenerated.$$"; - path("conf/$config_out.yml")->spew( YAML::Dump($config) ); + my $config_out = "general-test-autogenerated.$$.yml"; + path("conf/$config_out")->spew( YAML::Dump($config) ); local $ENV{FMS_OVERRIDE_CONFIG} = $config_out; diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index 46f5344e2..ac2ac023d 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -65,11 +65,12 @@ Create a test user (or find it and return if it already exists). sub create_user_ok { my $self = shift; - my ( $email, %extra ) = @_; + my ( $username, %extra ) = @_; - my $params = { email => $email, %extra }; + my $params = { %extra }; + $username =~ /@/ ? $params->{email} = $username : $params->{phone} = $username; my $user = FixMyStreet::DB->resultset('User')->find_or_create($params); - ok $user, "found/created user for $email"; + ok $user, "found/created user for $username"; return $user; } @@ -78,15 +79,15 @@ sub create_user_ok { $user = $mech->log_in_ok( $email_address ); -Log in with the email given. If email does not match an account then create one. +Log in with the email/phone given. If email/phone does not match an account then create one. =cut sub log_in_ok { my $mech = shift; - my $email = shift; + my $username = shift; - my $user = $mech->create_user_ok($email); + my $user = $mech->create_user_ok($username); # remember the old password and then change it to a known one my $old_password = $user->password || ''; @@ -95,7 +96,7 @@ sub log_in_ok { # log in $mech->get_ok('/auth'); $mech->submit_form_ok( - { with_fields => { email => $email, password_sign_in => 'secret' } }, + { with_fields => { username => $username, password_sign_in => 'secret' } }, "sign in using form" ); $mech->logged_in_ok; @@ -135,6 +136,7 @@ sub log_out_ok { $mech->delete_user( $user ); $mech->delete_user( $email ); + $mech->delete_user( $phone ); Delete the current user, including linked objects like problems etc. Can be either a user object or an email address. @@ -142,14 +144,14 @@ either a user object or an email address. =cut sub delete_user { - my $mech = shift; - my $email_or_user = shift; + my $mech = shift; + my $user_or_username = shift; - my $user = - ref $email_or_user - ? $email_or_user - : FixMyStreet::DB->resultset('User') - ->find( { email => $email_or_user } ); + my $user = ref $user_or_username ? $user_or_username : undef; + $user = FixMyStreet::DB->resultset('User')->find( { email => $user_or_username } ) + unless $user; + $user = FixMyStreet::DB->resultset('User')->find( { phone => $user_or_username } ) + unless $user; # If no user found we can't delete them return 1 unless $user; @@ -628,6 +630,14 @@ sub delete_defect_type { $defect_type->delete; } +sub delete_response_template { + my $mech = shift; + my $response_template = shift; + + $response_template->contact_response_templates->delete_all; + $response_template->delete; +} + sub create_contact_ok { my $self = shift; my %contact_params = ( @@ -667,8 +677,7 @@ sub create_problems_for_body { my $dt = $params->{dt} || DateTime->now(); my $user = $params->{user} || - FixMyStreet::DB->resultset('User') - ->find_or_create( { email => 'test@example.com', name => 'Test User' } ); + FixMyStreet::DB->resultset('User')->find_or_create( { email => 'test@example.com', name => 'Test User' } ); delete $params->{user}; delete $params->{dt}; @@ -720,4 +729,18 @@ sub get_photo_data { }; } +sub create_comment_for_problem { + my ( $mech, $problem, $user, $name, $text, $anonymous, $state, $problem_state, $params ) = @_; + $params ||= {}; + $params->{problem_id} = $problem->id; + $params->{user_id} = $user->id; + $params->{name} = $name; + $params->{text} = $text; + $params->{anonymous} = $anonymous; + $params->{problem_state} = $problem_state; + $params->{state} = $state; + $params->{mark_fixed} = $problem_state && FixMyStreet::DB::Result::Problem->fixed_states()->{$problem_state} ? 1 : 0; + + FixMyStreet::App->model('DB::Comment')->create($params); +} 1; |