diff options
Diffstat (limited to 'perllib/FixMyStreet')
53 files changed, 1939 insertions, 797 deletions
diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index 787755a05..c9286b177 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -191,6 +191,8 @@ sub setup_request { $c->log->debug( sprintf "Set lang to '%s' and cobrand to '%s'", $set_lang, $cobrand->moniker ); + $c->stash->{site_name} = Utils::trim_text($c->render_fragment('site-name.html')); + $c->model('DB::Problem')->set_restriction( $cobrand->site_key() ); Memcached::set_namespace( FixMyStreet->config('FMS_DB_NAME') . ":" ); @@ -205,7 +207,8 @@ sub setup_request { # XXX Put in cobrand / do properly if ($c->cobrand->moniker eq 'zurich') { - FixMyStreet::DB::Result::Problem->visible_states_add_unconfirmed(); + FixMyStreet::DB::Result::Problem->visible_states_add('unconfirmed'); + FixMyStreet::DB::Result::Problem->visible_states_remove('investigating'); } if (FixMyStreet->test_mode) { @@ -316,15 +319,15 @@ sub send_email { ] }; + return if $c->is_abuser($vars->{to}); + # render the template my $content = $c->view('Email')->render( $c, $template, $vars ); # create an email - will parse headers out of content my $email = Email::Simple->new($content); - $email->header_set( ucfirst($_), $vars->{$_} ) - for grep { $vars->{$_} } qw( to from subject); - - return if $c->is_abuser( $email->header('To') ); + $email->header_set( 'Subject', $vars->{subject} ) if $vars->{subject}; + $email->header_set( 'Reply-To', $vars->{'Reply-To'} ) if $vars->{'Reply-To'}; $email->header_set( 'Message-ID', sprintf('<fms-%s-%s@%s>', time(), unpack('h*', random_bytes(5, 1)), $c->config->{EMAIL_DOMAIN} @@ -337,10 +340,16 @@ sub send_email { _template_ => $email->body, # will get line wrapped _parameters_ => {}, _line_indent => '', + From => $vars->{from}, + To => $vars->{to}, $email->header_pairs } ) }; + if (my $attachments = $extra_stash_values->{attachments}) { + $email_text = munge_attachments($email_text, $attachments); + } + # send the email $c->model('EmailSend')->send($email_text); @@ -350,17 +359,14 @@ sub send_email { sub send_email_cron { my ( $c, $params, $env_from, $nomail, $cobrand, $lang_code ) = @_; - my $first_to; - if (ref($params->{To}) eq 'ARRAY') { - if (ref($params->{To}[0]) eq 'ARRAY') { - $first_to = $params->{To}[0][0]; - } else { - $first_to = $params->{To}[0]; - } - } else { - $first_to = $params->{To}; + my $sender = $c->config->{DO_NOT_REPLY_EMAIL}; + $env_from ||= $sender; + if (!$params->{From}) { + my $sender_name = $cobrand->contact_name; + $params->{From} = [ $sender, _($sender_name) ]; } - return 1 if $c->is_abuser($first_to); + + return 1 if $c->is_abuser($params->{To}); $params->{'Message-ID'} = sprintf('<fms-cron-%s-%s@%s>', time(), unpack('h*', random_bytes(5, 1)), FixMyStreet->config('EMAIL_DOMAIN') @@ -394,8 +400,12 @@ sub send_email_cron { $params->{_parameters_}->{site_name} = $site_name; $params->{_line_indent} = ''; + my $attachments = delete $params->{attachments}; + my $email = mySociety::Locale::in_gb_locale { mySociety::Email::construct_email($params) }; + $email = munge_attachments($email, $attachments) if $attachments; + if ($nomail) { print $email; return 1; # Failure @@ -409,6 +419,44 @@ sub send_email_cron { } } +sub munge_attachments { + my ($message, $attachments) = @_; + # $attachments should be an array_ref of things that can be parsed to Email::MIME, + # for example + # [ + # body => $binary_data, + # attributes => { + # content_type => 'image/jpeg', + # encoding => 'base64', + # filename => '1234.1.jpeg', + # name => '1234.1.jpeg', + # }, + # ... + # ] + # + # XXX: mySociety::Email::construct_email isn't using a MIME library and + # requires more analysis to refactor, so for now, we'll simply parse the + # generated MIME and add attachments. + # + # (Yes, this means that the email is constructed by Email::Simple, munged + # manually by custom code, turned back into Email::Simple, and then munged + # with Email::MIME. What's your point?) + + require Email::MIME; + my $mime = Email::MIME->new($message); + $mime->parts_add([ map { Email::MIME->create(%$_)} @$attachments ]); + my $data = $mime->as_string; + + # unsure why Email::MIME adds \r\n. Possibly mail client should handle + # gracefully, BUT perhaps as the segment constructed by + # mySociety::Email::construct_email strips to \n, they seem not to. + # So we re-run the same regexp here to the added part. + $data =~ s/\r\n/\n/gs; + + return $data; +} + + =head2 uri_with $uri = $c->uri_with( ... ); @@ -524,7 +572,17 @@ sub get_photo_params { } sub is_abuser { - my ($c, $email) = @_; + my ($c, $to) = @_; + my $email; + if (ref($to) eq 'ARRAY') { + if (ref($to->[0]) eq 'ARRAY') { + $email = $to->[0][0]; + } else { + $email = $to->[0]; + } + } else { + $email = $to; + } my ($domain) = $email =~ m{ @ (.*) \z }x; return $c->model('DB::Abuse')->search( { email => [ $email, $domain ] } )->first; } diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index 6145a6eb0..a61032988 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -10,6 +10,7 @@ use Digest::SHA qw(sha1_hex); use mySociety::EmailUtil qw(is_valid_email); use if !$ENV{TRAVIS}, 'Image::Magick'; use DateTime::Format::Strptime; +use List::Util 'first'; use FixMyStreet::SendReport; @@ -70,8 +71,6 @@ sub index : Path : Args(0) { return $c->cobrand->admin(); } - my $site_restriction = $c->cobrand->site_restriction(); - my $problems = $c->cobrand->problems->summary_count; my %prob_counts = @@ -85,7 +84,7 @@ sub index : Path : Args(0) { for ( FixMyStreet::DB::Result::Problem->visible_states() ); $c->stash->{total_problems_users} = $c->cobrand->problems->unique_users; - my $comments = $c->model('DB::Comment')->summary_count( $site_restriction ); + my $comments = $c->model('DB::Comment')->summary_count( $c->cobrand->body_restriction ); my %comment_counts = map { $_->state => $_->get_column('state_count') } $comments->all; @@ -130,7 +129,9 @@ sub index : Path : Args(0) { : _('n/a'); $c->stash->{questionnaires} = \%questionnaire_counts; - $c->stash->{categories} = $c->cobrand->problems->categories_summary(); + if ($c->get_param('show_categories')) { + $c->stash->{categories} = $c->cobrand->problems->categories_summary(); + } $c->stash->{total_bodies} = $c->model('DB::Body')->count(); @@ -150,7 +151,6 @@ sub config_page : Path( 'config' ) : Args(0) { sub timeline : Path( 'timeline' ) : Args(0) { my ($self, $c) = @_; - my $site_restriction = $c->cobrand->site_restriction(); my %time; $c->model('DB')->schema->storage->sql_maker->quote_char( '"' ); @@ -171,7 +171,7 @@ sub timeline : Path( 'timeline' ) : Args(0) { push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered; } - my $updates = $c->model('DB::Comment')->timeline( $site_restriction ); + my $updates = $c->model('DB::Comment')->timeline( $c->cobrand->body_restriction ); foreach ($updates->all) { push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ; @@ -359,13 +359,21 @@ sub update_contacts : Private { $contact->deleted( $c->get_param('deleted') ? 1 : 0 ); $contact->non_public( $c->get_param('non_public') ? 1 : 0 ); $contact->note( $c->get_param('note') ); - $contact->whenedited( \'ms_current_timestamp()' ); + $contact->whenedited( \'current_timestamp' ); $contact->editor( $editor ); $contact->endpoint( $c->get_param('endpoint') ); $contact->jurisdiction( $c->get_param('jurisdiction') ); $contact->api_key( $c->get_param('api_key') ); $contact->send_method( $c->get_param('send_method') ); + # Set the photo_required flag in extra to the appropriate value + if ( $c->get_param('photo_required') ) { + $contact->set_extra_metadata_if_undefined( photo_required => 1 ); + } + else { + $contact->unset_extra_metadata( 'photo_required' ); + } + if ( %errors ) { $c->stash->{updated} = _('Please correct the errors below'); $c->stash->{contact} = $contact; @@ -395,7 +403,7 @@ sub update_contacts : Private { $contacts->update( { confirmed => 1, - whenedited => \'ms_current_timestamp()', + whenedited => \'current_timestamp', note => 'Confirmed', editor => $editor, } @@ -538,8 +546,6 @@ sub reports : Path('reports') { if (my $search = $c->get_param('search')) { $c->stash->{searched} = $search; - my $site_restriction = $c->cobrand->site_restriction; - my $search_n = 0; $search_n = int($search) if $search =~ /^\d+$/; @@ -616,9 +622,10 @@ sub reports : Path('reports') { } if (@$query) { - my $updates = $c->model('DB::Comment')->search( + my $updates = $c->model('DB::Comment') + ->to_body($c->cobrand->body_restriction) + ->search( { - %{ $site_restriction }, -or => $query, }, { @@ -650,8 +657,6 @@ sub reports : Path('reports') { sub report_edit : Path('report_edit') : Args(1) { my ( $self, $c, $id ) = @_; - my $site_restriction = $c->cobrand->site_restriction; - my $problem = $c->cobrand->problems->search( { id => $id } )->first; $c->detach( '/page_error_404_not_found' ) @@ -675,12 +680,21 @@ sub report_edit : Path('report_edit') : Args(1) { type => 'big', } ] : [], + print_report => 1, ); } - if ( $c->get_param('rotate_photo') ) { - $c->forward('rotate_photo'); - return 1; + if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { + $self->rotate_photo($c, @$rotate_photo_param); + if ( $c->cobrand->moniker eq 'zurich' ) { + # Clicking the photo rotation buttons should do nothing + # except for rotating the photo, so return the user + # to the report screen now. + $c->res->redirect( $c->uri_for( 'report_edit', $problem->id ) ); + return; + } else { + return 1; + } } if ( $c->cobrand->moniker eq 'zurich' ) { @@ -707,7 +721,7 @@ sub report_edit : Path('report_edit') : Args(1) { } elsif ( $c->get_param('mark_sent') ) { $c->forward('check_token'); - $problem->whensent(\'ms_current_timestamp()'); + $problem->whensent(\'current_timestamp'); $problem->update(); $c->stash->{status_message} = '<p><em>' . _('That problem has been marked as sent.') . '</em></p>'; $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] ); @@ -787,14 +801,14 @@ sub report_edit : Path('report_edit') : Args(1) { } if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) { - $problem->confirmed( \'ms_current_timestamp()' ); + $problem->confirmed( \'current_timestamp' ); } if ($done) { $problem->discard_changes; } else { - $problem->lastupdate( \'ms_current_timestamp()' ) if $edited || $new_state ne $old_state; + $problem->lastupdate( \'current_timestamp' ) if $edited || $new_state ne $old_state; $problem->update; if ( $new_state ne $old_state ) { @@ -816,6 +830,85 @@ sub report_edit : Path('report_edit') : Args(1) { return 1; } +sub templates : Path('templates') : Args(0) { + my ( $self, $c ) = @_; + + $c->detach( '/page_error_404_not_found' ) + unless $c->cobrand->moniker eq 'zurich'; + + my $user = $c->user; + + $self->templates_for_body($c, $user->from_body ); +} + +sub templates_view : Path('templates') : Args(1) { + my ($self, $c, $body_id) = @_; + + $c->detach( '/page_error_404_not_found' ) + unless $c->cobrand->moniker eq 'zurich'; + + # e.g. for admin + + my $body = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found' ); + + $self->templates_for_body($c, $body); +} + +sub template_edit : Path('templates') : Args(2) { + my ( $self, $c, $body_id, $template_id ) = @_; + + $c->detach( '/page_error_404_not_found' ) + unless $c->cobrand->moniker eq 'zurich'; + + my $body = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found' ); + $c->stash->{body} = $body; + + my $template; + if ($template_id eq 'new') { + $template = $body->response_templates->new({}); + } + else { + $template = $body->response_templates->find( $template_id ) + or $c->detach( '/page_error_404_not_found' ); + } + + if ($c->req->method eq 'POST') { + if ($c->get_param('delete_template') eq _("Delete template")) { + $template->delete; + } else { + $template->title( $c->get_param('title') ); + $template->text ( $c->get_param('text') ); + $template->update_or_insert; + } + + $c->res->redirect( $c->uri_for( 'templates', $body->id ) ); + } + + $c->stash->{response_template} = $template; + + $c->stash->{template} = 'admin/template_edit.html'; +} + + +sub templates_for_body { + my ( $self, $c, $body ) = @_; + + $c->stash->{body} = $body; + + my @templates = $body->response_templates->search( + undef, + { + order_by => 'title' + } + ); + + $c->stash->{response_templates} = \@templates; + + $c->stash->{template} = 'admin/templates.html'; +} + sub users: Path('users') : Args(0) { my ( $self, $c ) = @_; @@ -874,13 +967,9 @@ sub users: Path('users') : Args(0) { sub update_edit : Path('update_edit') : Args(1) { my ( $self, $c, $id ) = @_; - my $site_restriction = $c->cobrand->site_restriction; - my $update = $c->model('DB::Comment')->search( - { - id => $id, - %{$site_restriction}, - } - )->first; + my $update = $c->model('DB::Comment') + ->to_body($c->cobrand->body_restriction) + ->search({ id => $id })->first; $c->detach( '/page_error_404_not_found' ) unless $update; @@ -943,10 +1032,10 @@ sub update_edit : Path('update_edit') : Args(1) { } if ( $new_state eq 'confirmed' and $old_state eq 'unconfirmed' ) { - $update->confirmed( \'ms_current_timestamp()' ); + $update->confirmed( \'current_timestamp' ); if ( $update->problem_state && $update->created > $update->problem->lastupdate ) { $update->problem->state( $update->problem_state ); - $update->problem->lastupdate( \'ms_current_timestamp()' ); + $update->problem->lastupdate( \'current_timestamp' ); $update->problem->update; } } @@ -1064,7 +1153,7 @@ sub user_edit : Path('user_edit') : Args(1) { sub flagged : Path('flagged') : Args(0) { my ( $self, $c ) = @_; - my $problems = $c->model('DB::Problem')->search( { flagged => 1 } ); + 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 @@ -1121,9 +1210,6 @@ sub stats : Path('stats') : Args(0) { my $bymonth = $c->get_param('bymonth'); $c->stash->{bymonth} = $bymonth; - my ( %body, %dates ); - $body{bodies_str} = { like => $c->get_param('body') } - if $c->get_param('body'); $c->stash->{selected_body} = $c->get_param('body'); @@ -1154,14 +1240,12 @@ sub stats : Path('stats') : Args(0) { ); } - my $p = $c->model('DB::Problem')->search( + my $p = $c->cobrand->problems->to_body($c->get_param('body'))->search( { -AND => [ $field => { '>=', $start_date}, $field => { '<=', $end_date + $one_day }, ], - %body, - %dates, }, \%select, ); @@ -1266,13 +1350,24 @@ Adds an entry into the admin_log table using the current user. =cut sub log_edit : Private { - my ( $self, $c, $id, $object_type, $action ) = @_; + my ( $self, $c, $id, $object_type, $action, $time_spent ) = @_; + + $time_spent //= 0; + $time_spent = 0 if $time_spent < 0; + + my $user_object = do { + my $auth_user = $c->user; + $auth_user ? $auth_user->get_object : undef; + }; + $c->model('DB::AdminLog')->create( { admin_user => $c->forward('get_user'), + $user_object ? ( user => $user_object ) : (), # as (rel => undef) doesn't work object_type => $object_type, action => $action, object_id => $id, + time_spent => $time_spent, } )->insert(); } @@ -1385,36 +1480,27 @@ Rotate a photo 90 degrees left or right =cut +# returns index of photo to rotate, if any +sub _get_rotate_photo_param { + my ($self, $c) = @_; + my $key = first { /^rotate_photo/ } keys %{ $c->req->params } or return; + my ($index) = $key =~ /(\d+)$/; + my $direction = $c->get_param($key); + return [ $index || 0, $key, $direction ]; +} + sub rotate_photo : Private { - my ( $self, $c ) =@_; + my ( $self, $c, $index, $key, $direction ) = @_; - my $direction = $c->get_param('rotate_photo'); return unless $direction eq _('Rotate Left') or $direction eq _('Rotate Right'); - my $photo = $c->stash->{problem}->photo; - my $file; + my $problem = $c->stash->{problem}; + my $fileid = $problem->get_photoset($c)->rotate_image( + $index, + $direction eq _('Rotate Left') ? -90 : 90 + ) or return; - # If photo field contains a hash - if ( length($photo) == 40 ) { - $file = file( $c->config->{UPLOAD_DIR}, "$photo.jpeg" ); - $photo = $file->slurp; - } - - $photo = _rotate_image( $photo, $direction eq _('Rotate Left') ? -90 : 90 ); - return unless $photo; - - # Write out to new location - my $fileid = sha1_hex($photo); - $file = file( $c->config->{UPLOAD_DIR}, "$fileid.jpeg" ); - - my $fh = $file->open('w'); - print $fh $photo; - close $fh; - - unlink glob FixMyStreet->path_to( 'web', 'photo', $c->stash->{problem}->id . '.*' ); - - $c->stash->{problem}->photo( $fileid ); - $c->stash->{problem}->update(); + $problem->update({ photo => $fileid }); return 1; } @@ -1464,18 +1550,6 @@ sub trim { return $e; } -sub _rotate_image { - my ($photo, $direction) = @_; - my $image = Image::Magick->new; - $image->BlobToImage($photo); - my $err = $image->Rotate($direction); - return 0 if $err; - my @blobs = $image->ImageToBlob(); - undef $image; - return $blobs[0]; -} - - =head1 AUTHOR Struan Donald diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index 723684793..4aa695ae5 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -28,7 +28,7 @@ If no search redirect back to the homepage. =cut -sub around_index : Path : Args(0) { +sub index : Path : Args(0) { my ( $self, $c ) = @_; # handle old coord systems @@ -302,15 +302,10 @@ sub ajax : Path('/ajax') { 'around/on_map_list_items.html', { on_map => $on_map, around_map => $around_map } ); - my $around_map_list_html = $c->render_fragment( - 'around/around_map_list_items.html', - { on_map => $on_map, around_map => $around_map } - ); # JSON encode the response my $json = { pins => $pins }; $json->{current} = $on_map_list_html if $on_map_list_html; - $json->{current_near} = $around_map_list_html if $around_map_list_html; my $body = JSON->new->utf8(1)->encode($json); $c->res->body($body); } diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 63bf91ff5..6de416c53 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -36,16 +36,23 @@ sub general : Path : Args(0) { return unless $c->req->method eq 'POST'; # decide which action to take - my $has_password = $c->get_param('sign_in') || $c->get_param('password_sign_in'); - my $has_email = $c->get_param('email_sign_in') || $c->get_param('name') || $c->get_param('password_register'); + my $clicked_password = $c->get_param('sign_in'); + my $clicked_email = $c->get_param('email_sign_in'); + my $data_password = $c->get_param('password_sign_in'); + my $data_email = $c->get_param('name') || $c->get_param('password_register'); - $c->detach('email_sign_in') if $has_email && !$has_password; + $c->detach('email_sign_in') if $clicked_email || ($data_email && !$data_password); $c->forward( 'sign_in' ) && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); } +sub general_test : Path('_test_') : Args(0) { + my ( $self, $c ) = @_; + $c->stash->{template} = 'auth/token.html'; +} + =head2 sign_in Allow the user to sign in with a username and a password. diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm index 912224649..115f4e3d2 100644 --- a/perllib/FixMyStreet/App/Controller/Contact.pm +++ b/perllib/FixMyStreet/App/Controller/Contact.pm @@ -5,6 +5,7 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } use mySociety::EmailUtil; +use FixMyStreet::Email; =head1 NAME @@ -239,11 +240,19 @@ sub send_email : Private { ? ' ( forwarded from ' . $c->req->header('X-Forwarded-For') . ' )' : ''; - $c->send_email( 'contact.txt', { + my $from = [ $c->stash->{em}, $c->stash->{form_name} ]; + my $params = { to => [ [ $recipient, _($recipient_name) ] ], - from => [ $c->stash->{em}, $c->stash->{form_name} ], subject => 'FMS message: ' . $c->stash->{subject}, - }); + }; + if (FixMyStreet::Email::test_dmarc($c->stash->{em})) { + $params->{'Reply-To'} = [ $from ]; + $params->{from} = [ $recipient, $c->stash->{form_name} ]; + } else { + $params->{from} = $from; + } + + $c->send_email('contact.txt', $params); # above is always succesful :( $c->stash->{success} = 1; diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index c3aa35008..faddaa89e 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -89,6 +89,7 @@ sub index : Path : Args(0) { my ( $self, $c ) = @_; my $body = $c->forward('check_page_allowed'); + $c->stash->{body} = $body; # Set up the data for the dropdowns @@ -112,7 +113,6 @@ sub index : Path : Args(0) { $c->stash->{category} = $c->get_param('category'); my %where = ( - bodies_str => $body->id, # XXX Does this break in a two tier council? Restriction needs looking at... 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ], ); $where{areas} = { 'like', '%,' . $c->stash->{ward} . ',%' } @@ -155,7 +155,7 @@ sub index : Path : Args(0) { %$prob_where, 'me.confirmed' => { '>=', $dtf->format_datetime( $now->clone->subtract( days => 30 ) ) }, }; - my $problems_rs = $c->cobrand->problems->search( $params ); + my $problems_rs = $c->cobrand->problems->to_body($body)->search( $params ); my @problems = $problems_rs->all; my %problems; @@ -270,12 +270,14 @@ sub export_as_csv { 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')->search( + my $comments = $c->model('DB::Comment')->to_body($body)->search( $params, { group_by => [ 'problem_state' ], @@ -302,7 +304,7 @@ sub updates_search : Private { 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')->search( + $comments = $c->model('DB::Comment')->to_body($body)->search( { %$params, problem_state => $vars, 'me.id' => \"= ($substmt)", @@ -319,7 +321,7 @@ sub updates_search : Private { $counts{$col} = int( ($comments->get_column('time')||0) / 60 / 60 / 24 + 0.5 ); } - $counts{fixed_user} = $c->model('DB::Comment')->search( + $counts{fixed_user} = $c->model('DB::Comment')->to_body($body)->search( { %$params, mark_fixed => 1, problem_state => undef }, { join => 'problem' } )->count; @@ -327,7 +329,7 @@ sub updates_search : Private { %{$c->stash->{prob_where}}, 'me.confirmed' => { '>=', $time }, }; - $counts{total} = $c->cobrand->problems->search( $params )->count; + $counts{total} = $c->cobrand->problems->to_body($body)->search( $params )->count; $params = { %{$c->stash->{prob_where}}, @@ -335,7 +337,7 @@ sub updates_search : Private { 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->search( $params )->count; + $counts{not_marked} = $c->cobrand->problems->to_body($body)->search( $params )->count; return \%counts; } diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index 08c4280a1..77a3346dc 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -102,9 +102,6 @@ sub report_moderate_audit : Private { my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($problem->cobrand)->new(); - my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL'); - my $sender_name = _($cobrand->contact_name); - my $token = $c->model("DB::Token")->create({ scope => 'moderation', data => { id => $problem->id } @@ -113,7 +110,6 @@ sub report_moderate_audit : Private { $c->send_email( 'problem-moderated.txt', { to => [ [ $user->email, $user->name ] ], - from => [ $sender, $sender_name ], types => $types_csv, user => $user, problem => $problem, diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm index 83d5f7adb..3c4ce2cf7 100644 --- a/perllib/FixMyStreet/App/Controller/My.pm +++ b/perllib/FixMyStreet/App/Controller/My.pm @@ -31,16 +31,12 @@ sub my : Path : Args(0) { $c->forward( '/reports/stash_report_filter_status' ); my $pins = []; - my $problems = {}; + my $problems = []; my $states = $c->stash->{filter_problem_states}; my $params = { state => [ keys %$states ], }; - $params = { - %{ $c->cobrand->problems_clause }, - %$params - } if $c->cobrand->problems_clause; my $category = $c->get_param('filter_category'); if ( $category ) { @@ -48,7 +44,9 @@ sub my : Path : Args(0) { $c->stash->{filter_category} = $category; } - my $rs = $c->user->problems->search( $params, { + my $rs = $c->user->problems + ->to_body($c->cobrand->body_restriction) + ->search( $params, { order_by => { -desc => 'confirmed' }, rows => 50 } )->page( $p_page ); @@ -62,9 +60,7 @@ sub my : Path : Args(0) { id => $problem->id, title => $problem->title, }; - my $state = $problem->is_fixed ? 'fixed' : $problem->is_closed ? 'closed' : 'confirmed'; - push @{ $problems->{$state} }, $problem; - push @{ $problems->{all} }, $problem; + push @$problems, $problem; } $c->stash->{problems_pager} = $rs->pager; $c->stash->{problems} = $problems; diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm index a2ec7d4c8..bc72f4bfb 100644 --- a/perllib/FixMyStreet/App/Controller/Photo.pm +++ b/perllib/FixMyStreet/App/Controller/Photo.pm @@ -8,8 +8,8 @@ use DateTime::Format::HTTP; use Digest::SHA qw(sha1_hex); use File::Path; use File::Slurp; -use Image::Size; use Path::Class; +use FixMyStreet::App::Model::PhotoSet; use if !$ENV{TRAVIS}, 'Image::Magick'; =head1 NAME @@ -49,13 +49,13 @@ sub during :LocalRegex('^([0-9a-f]{40})\.(temp|fulltemp)\.jpeg$') { $c->forward( 'output', [ $photo ] ); } -sub index :LocalRegex('^(c/)?(\d+)(?:\.(full|tn|fp))?\.jpeg$') { +sub index :LocalRegex('^(c/)?(\d+)(?:\.(\d+))?(?:\.(full|tn|fp))?\.jpeg$') { my ( $self, $c ) = @_; - my ( $is_update, $id, $size ) = @{ $c->req->captures }; + my ( $is_update, $id, $photo_number, $size ) = @{ $c->req->captures }; - my @photo; + my $item; if ( $is_update ) { - @photo = $c->model('DB::Comment')->search( { + ($item) = $c->model('DB::Comment')->search( { id => $id, state => 'confirmed', photo => { '!=', undef }, @@ -67,34 +67,40 @@ sub index :LocalRegex('^(c/)?(\d+)(?:\.(full|tn|fp))?\.jpeg$') { } $c->detach( 'no_photo' ) if $id =~ /\D/; - @photo = $c->cobrand->problems->search( { + ($item) = $c->cobrand->problems->search( { id => $id, state => [ FixMyStreet::DB::Result::Problem->visible_states(), 'partial' ], photo => { '!=', undef }, } ); } - $c->detach( 'no_photo' ) unless @photo; + $c->detach( 'no_photo' ) unless $item; - my $item = $photo[0]; $c->detach( 'no_photo' ) unless $c->cobrand->allow_photo_display($item); # Should only be for reports, not updates - my $photo = $item->photo; - # If photo field contains a hash - if (length($photo) == 40) { - my $file = file( $c->config->{UPLOAD_DIR}, "$photo.jpeg" ); - $photo = $file->slurp; - } - - if ( $size eq 'tn' ) { - $photo = _shrink( $photo, 'x100' ); - } elsif ( $size eq 'fp' ) { - $photo = _crop( $photo ); - } elsif ( $size eq 'full' ) { - } elsif ( $c->cobrand->default_photo_resize ) { - $photo = _shrink( $photo, $c->cobrand->default_photo_resize ); + my $photo; + if ($item->can('get_photoset')) { + $photo = $item->get_photoset( $c ) + ->get_image_data( num => $photo_number, size => $size ) + or $c->detach( 'no_photo' ); } else { - $photo = _shrink( $photo, '250x250' ); + $photo = $item->photo; + # If photo field contains a hash + if (length($photo) == 40) { + my $file = file( $c->config->{UPLOAD_DIR}, "$photo.jpeg" ); + $photo = $file->slurp; + } + + if ( $size eq 'tn' ) { + $photo = _shrink( $photo, 'x100' ); + } elsif ( $size eq 'fp' ) { + $photo = _crop( $photo ); + } elsif ( $size eq 'full' ) { + } elsif ( $c->cobrand->default_photo_resize ) { + $photo = _shrink( $photo, $c->cobrand->default_photo_resize ); + } else { + $photo = _shrink( $photo, '250x250' ); + } } $c->forward( 'output', [ $photo ] ); @@ -156,84 +162,67 @@ sub process_photo : Private { my ( $self, $c ) = @_; return - $c->forward('process_photo_upload') - || $c->forward('process_photo_cache') + $c->forward('process_photo_upload_or_cache') + || $c->forward('process_photo_required') || 1; # always return true } -sub process_photo_upload : Private { +sub process_photo_upload_or_cache : Private { my ( $self, $c ) = @_; - - # check for upload or return - my $upload = $c->req->upload('photo') - || return; - - # check that the photo is a jpeg - my $ct = $upload->type; - $ct =~ s/x-citrix-//; # Thanks, Citrix - # Had a report of a JPEG from an Android 2.1 coming through as a byte stream - unless ( $ct eq 'image/jpeg' || $ct eq 'image/pjpeg' || $ct eq 'application/octet-stream' ) { - $c->log->info('Bad photo tried to upload, type=' . $ct); - $c->stash->{photo_error} = _('Please upload a JPEG image only'); - return; - } - - # get the photo into a variable - my $photo_blob = eval { - my $filename = $upload->tempname; - my $out = `jhead -se -autorot $filename 2>&1`; - unless (defined $out) { - my ($w, $h, $err) = Image::Size::imgsize($filename); - die _("Please upload a JPEG image only") . "\n" if !defined $w || $err ne 'JPG'; - } - die _("Please upload a JPEG image only") . "\n" if $out && $out =~ /Not JPEG:/; - my $photo = $upload->slurp; - return $photo; - }; - if ( my $error = $@ ) { - my $format = _( -"That image doesn't appear to have uploaded correctly (%s), please try again." - ); - $c->stash->{photo_error} = sprintf( $format, $error ); - return; - } - - # we have an image we can use - save it to the upload dir for storage - my $cache_dir = dir( $c->config->{UPLOAD_DIR} ); - $cache_dir->mkpath; - unless ( -d $cache_dir && -w $cache_dir ) { - warn "Can't find/write to photo cache directory '$cache_dir'"; - return; - } - - my $fileid = sha1_hex($photo_blob); - $upload->copy_to( file($cache_dir, $fileid . '.jpeg') ); - - # stick the hash on the stash, so don't have to reupload in case of error - $c->stash->{upload_fileid} = $fileid; - + my @items = ( + ( map { + /^photo/ ? # photo, photo1, photo2 etc. + ($c->req->upload($_)) : () + } sort $c->req->upload), + split /,/, ($c->get_param('upload_fileid') || '') + ); + + my $photoset = FixMyStreet::App::Model::PhotoSet->new({ + c => $c, + data_items => \@items, + }); + + my $fileid = $photoset->data; + + $c->stash->{upload_fileid} = $fileid or return; return 1; } -=head2 process_photo_cache +=head2 process_photo_required + +Checks that a report has a photo attached if any of its Contacts +require it (by setting extra->photo_required == 1). Puts an error in +photo_error on the stash if it's required and missing, otherwise returns +true. -Look for the upload_fileid parameter and check it matches a file on disk. If it -does return true and put fileid on stash, otherwise false. +(Note that as we have reached this action, we *know* that the photo +is missing, otherwise it would have already been handled.) =cut -sub process_photo_cache : Private { +sub process_photo_required : Private { my ( $self, $c ) = @_; - # get the fileid and make sure it is just a hex number - my $fileid = $c->get_param('upload_fileid') || ''; - $fileid =~ s{[^0-9a-f]}{}gi; - return unless $fileid; - - my $file = file( $c->config->{UPLOAD_DIR}, "$fileid.jpeg" ); - return unless -e $file; + # load the report + my $report = $c->stash->{report} or return 1; # don't check photo for updates + my $bodies = $c->stash->{bodies}; + + my @contacts = $c-> # + model('DB::Contact') # + ->not_deleted # + ->search( + { + body_id => [ keys %$bodies ], + category => $report->category + } + )->all; + foreach my $contact ( @contacts ) { + if ( $contact->get_extra_metadata('photo_required') ) { + $c->stash->{photo_error} = _("Photo is required."); + return; + } + } - $c->stash->{upload_fileid} = $fileid; return 1; } diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm index f9a08e408..8fe2514c0 100755 --- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm +++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm @@ -142,8 +142,8 @@ sub submit_creator_fixed : Private { $questionnaire->ever_reported( $c->stash->{reported} eq 'Yes' ? 1 : 0 ); $questionnaire->old_state( $old_state ); - $questionnaire->whensent( \'ms_current_timestamp()' ); - $questionnaire->whenanswered( \'ms_current_timestamp()' ); + $questionnaire->whensent( \'current_timestamp' ); + $questionnaire->whenanswered( \'current_timestamp' ); $questionnaire->insert; } @@ -173,13 +173,13 @@ sub submit_standard : Private { # Record state change, if there was one if ( $new_state ) { $problem->state( $new_state ); - $problem->lastupdate( \'ms_current_timestamp()' ); + $problem->lastupdate( \'current_timestamp' ); } # If it's not fixed and they say it's still not been fixed, record time update if ( $c->stash->{been_fixed} eq 'No' && FixMyStreet::DB::Result::Problem->open_states->{$old_state} ) { - $problem->lastupdate( \'ms_current_timestamp()' ); + $problem->lastupdate( \'current_timestamp' ); } # Record questionnaire response @@ -189,7 +189,7 @@ sub submit_standard : Private { my $q = $c->stash->{questionnaire}; $q->update( { - whenanswered => \'ms_current_timestamp()', + whenanswered => \'current_timestamp', ever_reported => $reported, old_state => $old_state, new_state => $c->stash->{been_fixed} eq 'Unknown' ? 'unknown' : ($new_state || $old_state), @@ -210,7 +210,7 @@ sub submit_standard : Private { lang => $c->stash->{lang_code}, cobrand => $c->cobrand->moniker, cobrand_data => '', - confirmed => \'ms_current_timestamp()', + confirmed => \'current_timestamp', anonymous => $problem->anonymous, } ); @@ -234,6 +234,8 @@ sub process_questionnaire : Private { map { $c->stash->{$_} = $c->get_param($_) || '' } qw(been_fixed reported another update); + $c->stash->{update} = Utils::cleanup_text($c->stash->{update}, { allow_multiline => 1 }); + # EHA questionnaires done for you if ($c->cobrand->moniker eq 'emptyhomes') { $c->stash->{another} = $c->stash->{num_questionnaire}==1 ? 'Yes' : 'No'; diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 7b001ee4c..d7cae05d4 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -99,23 +99,30 @@ sub load_problem_or_display_error : Private { my $problem = ( !$id || $id =~ m{\D} ) # is id non-numeric? ? undef # ...don't even search - : $c->cobrand->problems->find( { id => $id } ); + : $c->cobrand->problems->find( { id => $id } ) + or $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ); # check that the problem is suitable to show. - if ( !$problem || ($problem->state eq 'unconfirmed' && !$c->cobrand->show_unconfirmed_reports) || $problem->state eq 'partial' ) { + # hidden_states includes partial and unconfirmed, but they have specific handling, + # so we check for them first. + if ( $problem->state eq 'partial' ) { $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ); } - elsif ( $problem->state eq 'hidden' ) { + elsif ( $problem->state eq 'unconfirmed' ) { + $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] ) + unless $c->cobrand->show_unconfirmed_reports ; + } + elsif ( $problem->hidden_states->{ $problem->state } or + (($problem->get_extra_metadata('closure_status')||'') eq 'hidden')) { $c->detach( '/page_error_410_gone', [ _('That report has been removed from FixMyStreet.') ] # ); } elsif ( $problem->non_public ) { if ( !$c->user || $c->user->id != $problem->user->id ) { - my $site_name = Utils::trim_text($c->render_fragment('site-name.html')); $c->detach( '/page_error_403_access_denied', - [ sprintf(_('That report cannot be viewed on %s.'), $site_name) ] # + [ sprintf(_('That report cannot be viewed on %s.'), $c->stash->{site_name}) ] ); } } @@ -242,7 +249,7 @@ sub delete :Local :Args(1) { return $c->res->redirect($uri) unless $p->bodies->{$body->id}; $p->state('hidden'); - $p->lastupdate( \'ms_current_timestamp()' ); + $p->lastupdate( \'current_timestamp' ); $p->update; $c->model('DB::AdminLog')->create( { diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index b540a1961..246facbee 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -93,6 +93,7 @@ sub report_new : Path : Args(0) { $c->forward('check_for_category'); # deal with the user and report and check both are happy + return unless $c->forward('check_form_submitted'); $c->forward('process_user'); $c->forward('process_report'); @@ -106,6 +107,12 @@ sub report_new : Path : Args(0) { # 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'); +} + sub report_new_ajax : Path('mobile') : Args(0) { my ( $self, $c ) = @_; @@ -284,7 +291,7 @@ sub report_import : Path('/import') { } # handle the photo upload - $c->forward( '/photo/process_photo_upload' ); + $c->forward( '/photo/process_photo' ); my $fileid = $c->stash->{upload_fileid}; if ( my $error = $c->stash->{photo_error} ) { push @errors, $error; @@ -883,13 +890,29 @@ sub process_report : Private { $report->bodies_str(-1); } else { # construct the bodies string: - # 'x,x' - x are body IDs that have this category - # 'x,x|y' - x are body IDs that have this category, y body IDs with *no* contact - my $body_string = join( ',', map { $_->body_id } @contacts ); - $body_string .= - '|' . join( ',', map { $_->id } @{ $c->stash->{missing_details_bodies} } ) - if $body_string && @{ $c->stash->{missing_details_bodies} }; + my $body_string = do { + if ( $c->cobrand->can('singleton_bodies_str') && $c->cobrand->singleton_bodies_str ) { + # Cobrands like Zurich can only ever have a single body: 'x', because some functionality + # relies on string comparison against bodies_str. + if (@contacts) { + $contacts[0]->body_id; + } + else { + ''; + } + } + else { + # 'x,x' - x are body IDs that have this category + my $bs = join( ',', map { $_->body_id } @contacts ); + $bs; + }; + }; $report->bodies_str($body_string); + # Record any body IDs which might have meant to match, but had no contact + if ($body_string && @{ $c->stash->{missing_details_bodies} }) { + my $missing = join( ',', map { $_->id } @{ $c->stash->{missing_details_bodies} } ); + $report->bodies_missing($missing); + } } my @extra; @@ -1142,6 +1165,9 @@ sub redirect_or_confirm_creation : Private { return 1; } + my $template = 'problem-confirm.txt'; + $template = 'problem-confirm-not-sending.txt' unless $report->bodies_str; + # otherwise create a confirm token and email it to them. my $data = $c->stash->{token_data} || {}; my $token = $c->model("DB::Token")->create( { @@ -1152,7 +1178,10 @@ sub redirect_or_confirm_creation : Private { } } ); $c->stash->{token_url} = $c->uri_for_email( '/P', $token->token ); - $c->send_email( 'problem-confirm.txt', { + if ($c->cobrand->can('problem_confirm_email_extras')) { + $c->cobrand->problem_confirm_email_extras($report); + } + $c->send_email( $template, { to => [ $report->name ? [ $report->user->email, $report->name ] : $report->user->email ], } ); diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index 17aec2113..445723fec 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -80,7 +80,7 @@ sub update_problem : Private { $problem->interest_count( \'interest_count + 1' ); } - $problem->lastupdate( \'ms_current_timestamp()' ); + $problem->lastupdate( \'current_timestamp' ); $problem->update; $c->stash->{problem_id} = $problem->id; diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index 6b0d516a6..407fc625e 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -33,6 +33,7 @@ sub index : Path : Args(0) { # Zurich goes straight to map page, with all reports if ( $c->cobrand->moniker eq 'zurich' ) { + $c->forward( 'stash_report_filter_status' ); $c->forward( 'load_and_group_problems' ); my $pins = $c->stash->{pins}; $c->stash->{page} = 'reports'; @@ -121,7 +122,7 @@ sub ward : Path : Args(2) { $c->stash->{stats} = $c->cobrand->get_report_stats(); - my @categories = $c->stash->{body}->contacts->search( undef, { + my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, { columns => [ 'category' ], distinct => 1, order_by => [ 'category' ], @@ -269,9 +270,7 @@ sub rss_ward : Path('/rss/reports') : Args(2) { # Problems sent to a council $c->stash->{type} = 'council_problems'; $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name }; - # XXX This looks up in both bodies_str and areas, but is only using body ID. - # This will not work properly in any install where body IDs are not === area IDs. - $c->stash->{db_params} = [ $c->stash->{body}->id, $c->stash->{body}->id ]; + $c->stash->{db_params} = [ $c->stash->{body}->id ]; } # Send on to the RSS generation @@ -396,20 +395,20 @@ sub load_and_group_problems : Private { my $not_open = [ FixMyStreet::DB::Result::Problem::fixed_states(), FixMyStreet::DB::Result::Problem::closed_states() ]; if ( $type eq 'new' ) { - $where->{confirmed} = { '>', \"ms_current_timestamp() - INTERVAL '4 week'" }; + $where->{confirmed} = { '>', \"current_timestamp - INTERVAL '4 week'" }; $where->{state} = { 'IN', [ FixMyStreet::DB::Result::Problem::open_states() ] }; } elsif ( $type eq 'older' ) { - $where->{confirmed} = { '<', \"ms_current_timestamp() - INTERVAL '4 week'" }; - $where->{lastupdate} = { '>', \"ms_current_timestamp() - INTERVAL '8 week'" }; + $where->{confirmed} = { '<', \"current_timestamp - INTERVAL '4 week'" }; + $where->{lastupdate} = { '>', \"current_timestamp - INTERVAL '8 week'" }; $where->{state} = { 'IN', [ FixMyStreet::DB::Result::Problem::open_states() ] }; } elsif ( $type eq 'unknown' ) { - $where->{lastupdate} = { '<', \"ms_current_timestamp() - INTERVAL '8 week'" }; + $where->{lastupdate} = { '<', \"current_timestamp - INTERVAL '8 week'" }; $where->{state} = { 'IN', [ FixMyStreet::DB::Result::Problem::open_states() ] }; } elsif ( $type eq 'fixed' ) { - $where->{lastupdate} = { '>', \"ms_current_timestamp() - INTERVAL '8 week'" }; + $where->{lastupdate} = { '>', \"current_timestamp - INTERVAL '8 week'" }; $where->{state} = $not_open; } elsif ( $type eq 'older_fixed' ) { - $where->{lastupdate} = { '<', \"ms_current_timestamp() - INTERVAL '8 week'" }; + $where->{lastupdate} = { '<', \"current_timestamp - INTERVAL '8 week'" }; $where->{state} = $not_open; } @@ -417,29 +416,16 @@ sub load_and_group_problems : Private { $where->{category} = $category; } + my $problems = $c->cobrand->problems; + if ($c->stash->{ward}) { $where->{areas} = { 'like', '%,' . $c->stash->{ward}->{id} . ',%' }; - $where->{bodies_str} = [ - undef, - $c->stash->{body}->id, - { 'like', $c->stash->{body}->id . ',%' }, - { 'like', '%,' . $c->stash->{body}->id }, - ]; + $problems = $problems->to_body($c->stash->{body}); } elsif ($c->stash->{body}) { - # XXX FixMyStreet used to have the following line so that reports not - # currently sent anywhere could still be listed in the appropriate - # (body/area), as they were the same. Now they're not, not sure if - # there's a way to do this easily. - #$where->{areas} = { 'like', '%,' . $c->stash->{body}->id . ',%' }; - $where->{bodies_str} = [ - # undef, - $c->stash->{body}->id, - { 'like', $c->stash->{body}->id . ',%' }, - { 'like', '%,' . $c->stash->{body}->id }, - ]; + $problems = $problems->to_body($c->stash->{body}); } - my $problems = $c->cobrand->problems->search( + $problems = $problems->search( $where, { order_by => $c->cobrand->reports_ordering, @@ -463,7 +449,6 @@ sub load_and_group_problems : Private { } } else { # Add to bodies it was sent to - # XXX Assumes body ID matches "council ID" my $bodies = $problem->bodies_str_ids; foreach ( @$bodies ) { next if $_ != $c->stash->{body}->id; @@ -507,6 +492,9 @@ sub stash_report_filter_status : Private { } elsif ( $status eq 'open' ) { $c->stash->{filter_status} = 'open'; $c->stash->{filter_problem_states} = FixMyStreet::DB::Result::Problem->open_states(); + } elsif ( $status eq 'closed' ) { + $c->stash->{filter_status} = 'closed'; + $c->stash->{filter_problem_states} = FixMyStreet::DB::Result::Problem->closed_states(); } elsif ( $status eq 'fixed' ) { $c->stash->{filter_status} = 'fixed'; $c->stash->{filter_problem_states} = FixMyStreet::DB::Result::Problem->fixed_states(); diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm index 7aafc99ff..b817fe326 100755 --- a/perllib/FixMyStreet/App/Controller/Rss.pm +++ b/perllib/FixMyStreet/App/Controller/Rss.pm @@ -264,11 +264,7 @@ sub add_row : Private { (my $link = $alert_type->item_link) =~ s/{{(.*?)}}/$row->{$1}/g; (my $desc = _($alert_type->item_description)) =~ s/{{(.*?)}}/$row->{$1}/g; - my $hashref_restriction = $c->cobrand->site_restriction; - my $base_url = $c->cobrand->base_url; - if ( $hashref_restriction && $hashref_restriction->{bodies_str} && $row->{bodies_str} && $row->{bodies_str} ne $hashref_restriction->{bodies_str} ) { - $base_url = $c->config->{BASE_URL}; - } + my $base_url = $c->cobrand->base_url_for_report($row); my $url = $base_url . $link; my %item = ( diff --git a/perllib/FixMyStreet/App/Controller/Status.pm b/perllib/FixMyStreet/App/Controller/Status.pm new file mode 100755 index 000000000..907fe5456 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Status.pm @@ -0,0 +1,70 @@ +package FixMyStreet::App::Controller::Status; +use Moose; +use namespace::autoclean; + +use HTTP::Negotiate; +use JSON; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Status - Catalyst Controller + +=head1 DESCRIPTION + +Status page Catalyst Controller. + +=head1 METHODS + +=cut + +sub index_json : Path('/status.json') : Args(0) { + my ($self, $c) = @_; + $c->forward('index', [ 'json' ]); +} + +sub index : Path : Args(0) { + my ($self, $c, $format) = @_; + + # Fetch summary stats from admin front page + $c->forward('/admin/index'); + + # Fetch git version + $c->forward('/admin/config_page'); + + my $chosen = $format; + unless ($chosen) { + my $variants = [ + ['html', undef, 'text/html', undef, undef, undef, undef], + ['json', undef, 'application/json', undef, undef, undef, undef], + ]; + $chosen = HTTP::Negotiate::choose($variants, $c->req->headers); + $chosen = 'html' unless $chosen; + } + + # TODO Perform health checks here + + if ($chosen eq 'json') { + $c->res->content_type('application/json; charset=utf-8'); + my $data = { + version => $c->stash->{git_version}, + reports => $c->stash->{total_problems_live}, + updates => $c->stash->{comments}{confirmed}, + alerts_confirmed => $c->stash->{alerts}{1}, + alerts_unconfirmed => $c->stash->{alerts}{0}, + questionnaires_sent => $c->stash->{questionnaires}{total}, + questionnaires_answered => $c->stash->{questionnaires}{1}, + bodies => $c->stash->{total_bodies}, + contacts => $c->stash->{contacts}{total}, + }; + my $body = JSON->new->utf8(1)->pretty->encode($data); + $c->res->body($body); + } + + return 1; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm index 21c269502..ba15162ce 100644 --- a/perllib/FixMyStreet/App/Controller/Tokens.pm +++ b/perllib/FixMyStreet/App/Controller/Tokens.pm @@ -58,7 +58,7 @@ sub confirm_problem : Path('/P') { # 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 => \'ms_current_timestamp()' } ); + { state => 'hidden', lastupdate => \'current_timestamp' } ); $c->stash->{template} = 'tokens/abuse.html'; return; } @@ -68,7 +68,7 @@ sub confirm_problem : Path('/P') { if ($c->cobrand->moniker eq 'zurich') { $problem->set_extra_metadata( email_confirmed => 1 ); $problem->update( { - confirmed => \'ms_current_timestamp()', + confirmed => \'current_timestamp', } ); if ( $data->{name} || $data->{password} ) { @@ -90,8 +90,8 @@ sub confirm_problem : Path('/P') { $problem->update( { state => 'confirmed', - confirmed => \'ms_current_timestamp()', - lastupdate => \'ms_current_timestamp()', + confirmed => \'current_timestamp', + lastupdate => \'current_timestamp', } ); diff --git a/perllib/FixMyStreet/App/Model/PhotoSet.pm b/perllib/FixMyStreet/App/Model/PhotoSet.pm new file mode 100644 index 000000000..b18460821 --- /dev/null +++ b/perllib/FixMyStreet/App/Model/PhotoSet.pm @@ -0,0 +1,306 @@ +package FixMyStreet::App::Model::PhotoSet; + +# TODO this isn't a Cat model, rename to something else + +use Moose; +use Path::Tiny 'path'; +use if !$ENV{TRAVIS}, 'Image::Magick'; +use Scalar::Util 'openhandle', 'blessed'; +use Digest::SHA qw(sha1_hex); +use Image::Size; +use MIME::Base64; + +has c => ( + is => 'ro', +); + +has object => ( + is => 'ro', +); + +has data => ( # generic data from DB field + is => 'ro', + lazy => 1, + default => sub { + # yes, this is a little circular: data -> data_items -> items -> data + # e.g. if not provided, then we're presumably uploading/etc., so calculate from + # the stored cached fileids + # (obviously if you provide none of these, then you'll get an infinite loop) + my $self = shift; + my $data = join ',', map { $_->[0] } $self->all_images; + return $data; + } +); + +has data_items => ( # either a) split from data or b) provided by photo upload + isa => 'ArrayRef', + is => 'rw', + traits => ['Array'], + lazy => 1, + handles => { + map_data_items => 'map', + }, + default => sub { + my $self = shift; + my $data = $self->data + or return []; + + return [$data] if (_jpeg_magic($data)); + + return [ split ',' => $data ]; + }, +); + +has upload_dir => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $cache_dir = path( $self->c->config->{UPLOAD_DIR} ); + $cache_dir->mkpath; + unless ( -d $cache_dir && -w $cache_dir ) { + warn "Can't find/write to photo cache directory '$cache_dir'"; + return; + } + $cache_dir; + }, +); + +sub _jpeg_magic { + $_[0] =~ /^\x{ff}\x{d8}/; # JPEG + # NB: should we also handle \x{89}\x{50} (PNG, 15 results in live DB) ? + # and \x{49}\x{49} (Tiff, 3 results in live DB) ? +} + +=head2 C<images>, C<num_images>, C<get_raw_image_data>, C<all_images> + +C<$photoset-E<GT>images> is an AoA containing the filed and the binary image data. + + [ + [ $fileid1, $binary_data ], + [ $fileid2, $binary_data ], + ... + ] + +Various accessors are provided onto it: + + num_images: count + get_raw_image_data ($index): return the [$fileid, $binary_data] tuple + all_images: return AoA as an array (e.g. rather than arrayref) + +=cut + +has images => ( # AoA of [$fileid, $binary_data] tuples + isa => 'ArrayRef', + is => 'rw', + traits => ['Array'], + lazy => 1, + handles => { + num_images => 'count', + get_raw_image_data => 'get', + all_images => 'elements', + }, + default => sub { + my $self = shift; + my @photos = $self->map_data_items( sub { + my $part = $_; + + if (blessed $part and $part->isa('Catalyst::Request::Upload')) { + # check that the photo is a jpeg + my $upload = $part; + my $ct = $upload->type; + $ct =~ s/x-citrix-//; # Thanks, Citrix + # Had a report of a JPEG from an Android 2.1 coming through as a byte stream + unless ( $ct eq 'image/jpeg' || $ct eq 'image/pjpeg' || $ct eq 'application/octet-stream' ) { + my $c = $self->c; + $c->log->info('Bad photo tried to upload, type=' . $ct); + $c->stash->{photo_error} = _('Please upload a JPEG image only'); + return (); + } + + # base64 decode the file if it's encoded that way + # Catalyst::Request::Upload doesn't do this automatically + # unfortunately. + my $transfer_encoding = $upload->headers->header('Content-Transfer-Encoding'); + if (defined $transfer_encoding && $transfer_encoding eq 'base64') { + my $decoded = decode_base64($upload->slurp); + if (open my $fh, '>', $upload->tempname) { + binmode $fh; + print $fh $decoded; + close $fh + } else { + my $c = $self->c; + $c->log->info('Couldn\'t open temp file to save base64 decoded image: ' . $!); + $c->stash->{photo_error} = _("Sorry, we couldn't save your image(s), please try again."); + return (); + } + } + + # get the photo into a variable + my $photo_blob = eval { + my $filename = $upload->tempname; + my $out = `jhead -se -autorot $filename 2>&1`; + unless (defined $out) { + my ($w, $h, $err) = Image::Size::imgsize($filename); + die _("Please upload a JPEG image only") . "\n" if !defined $w || $err ne 'JPG'; + } + die _("Please upload a JPEG image only") . "\n" if $out && $out =~ /Not JPEG:/; + my $photo = $upload->slurp; + }; + if ( my $error = $@ ) { + my $format = _( + "That image doesn't appear to have uploaded correctly (%s), please try again." + ); + $self->c->stash->{photo_error} = sprintf( $format, $error ); + return (); + } + + # we have an image we can use - save it to the upload dir for storage + my $fileid = $self->get_fileid($photo_blob); + my $file = $self->get_file($fileid); + $upload->copy_to( $file ); + return [$fileid, $photo_blob]; + + } + if (_jpeg_magic($part)) { + my $photo_blob = $part; + my $fileid = $self->get_fileid($photo_blob); + my $file = $self->get_file($fileid); + $file->spew_raw($photo_blob); + return [$fileid, $photo_blob]; + } + if (length($part) == 40) { + my $fileid = $part; + my $file = $self->get_file($fileid); + if ($file->exists) { + my $photo = $file->slurp_raw; + [$fileid, $photo]; + } + else { + warn "File $fileid doesn't exist"; + (); + } + } + else { + warn sprintf "Received bad photo hash of length %d", length($part); + (); + } + }); + return \@photos; + }, +); + +sub get_fileid { + my ($self, $photo_blob) = @_; + return sha1_hex($photo_blob); +} + +sub get_file { + my ($self, $fileid) = @_; + my $cache_dir = $self->upload_dir; + return path( $cache_dir, "$fileid.jpeg" ); +} + +sub get_image_data { + my ($self, %args) = @_; + my $num = $args{num} || 0; + + my $data = $self->get_raw_image_data( $num ) + or return; + + my ($fileid, $photo) = @$data; + + my $size = $args{size}; + if ( $size eq 'tn' ) { + $photo = _shrink( $photo, 'x100' ); + } elsif ( $size eq 'fp' ) { + $photo = _crop( $photo ); + } elsif ( $size eq 'full' ) { + # do nothing + } else { + $photo = _shrink( $photo, $self->c->cobrand->default_photo_resize || '250x250' ); + } + + return $photo; +} + +sub delete_cached { + my ($self) = @_; + my $object = $self->object or return; + + unlink glob FixMyStreet->path_to( + 'web', + 'photo', + $object->id . '.*' + ); +} + +sub rotate_image { + my ($self, $index, $direction) = @_; + + my @images = $self->all_images; + return if $index > $#images; + + my @items = map $_->[0], @images; + $items[$index] = _rotate_image( $images[$index][1], $direction ); + + my $new_set = (ref $self)->new({ + data_items => \@items, + c => $self->c, + object => $self->object, + }); + + $self->delete_cached(); + + return $new_set->data; # e.g. new comma-separated fileid +} + +sub _rotate_image { + my ($photo, $direction) = @_; + return $photo unless $Image::Magick::VERSION; + my $image = Image::Magick->new; + $image->BlobToImage($photo); + my $err = $image->Rotate($direction); + return 0 if $err; + my @blobs = $image->ImageToBlob(); + undef $image; + return $blobs[0]; +} + + + + + +# NB: These 2 subs stolen from A::C::Photo, should be purged from there! +# +# Shrinks a picture to the specified size, but keeping in proportion. +sub _shrink { + my ($photo, $size) = @_; + return $photo unless $Image::Magick::VERSION; + my $image = Image::Magick->new; + $image->BlobToImage($photo); + my $err = $image->Scale(geometry => "$size>"); + throw Error::Simple("resize failed: $err") if "$err"; + $image->Strip(); + my @blobs = $image->ImageToBlob(); + undef $image; + return $blobs[0]; +} + +# Shrinks a picture to 90x60, cropping so that it is exactly that. +sub _crop { + my ($photo) = @_; + return $photo unless $Image::Magick::VERSION; + my $image = Image::Magick->new; + $image->BlobToImage($photo); + my $err = $image->Resize( geometry => "90x60^" ); + throw Error::Simple("resize failed: $err") if "$err"; + $err = $image->Extent( geometry => '90x60', gravity => 'Center' ); + throw Error::Simple("resize failed: $err") if "$err"; + $image->Strip(); + my @blobs = $image->ImageToBlob(); + undef $image; + return $blobs[0]; +} + +1; diff --git a/perllib/FixMyStreet/App/View/Web.pm b/perllib/FixMyStreet/App/View/Web.pm index da549ece8..a92021f0c 100644 --- a/perllib/FixMyStreet/App/View/Web.pm +++ b/perllib/FixMyStreet/App/View/Web.pm @@ -11,7 +11,7 @@ use Utils; __PACKAGE__->config( TEMPLATE_EXTENSION => '.html', - INCLUDE_PATH => [ # + INCLUDE_PATH => [ FixMyStreet->path_to( 'templates', 'web', 'base' ), ], ENCODING => 'utf8', @@ -38,17 +38,26 @@ TT View for FixMyStreet::App. =cut +# Override parent function so that errors are only logged once. +sub _rendering_error { + my ($self, $c, $err) = @_; + my $error = qq/Couldn't render template "$err"/; + # $c->log->error($error); + $c->error($error); + return 0; +} + =head2 loc - [% loc('Some text to localize') %] + [% loc('Some text to localize', 'Optional comment for translator') %] Passes the text to the localisation engine for translations. =cut sub loc { - my ( $self, $c, @args ) = @_; - return _(@args); + my ( $self, $c, $msgid ) = @_; + return _($msgid); } =head2 nget @@ -133,6 +142,9 @@ sub escape_js { '>' => 'u003e', ); $text =~ s/([\\"'<>])/\\$lookup{$1}/g; + + $text =~ s/(?:\r\n|\n|\r)/\\n/g; # replace newlines + return $text; } diff --git a/perllib/FixMyStreet/Cobrand/Bromley.pm b/perllib/FixMyStreet/Cobrand/Bromley.pm index 9bee45128..687843a2a 100644 --- a/perllib/FixMyStreet/Cobrand/Bromley.pm +++ b/perllib/FixMyStreet/Cobrand/Bromley.pm @@ -20,15 +20,7 @@ sub disambiguate_location { my $town = 'Bromley'; - # Bing turns High St Bromley into Bromley High St which is in - # Bromley by Bow. - $town .= ', BR1' if $string =~ /^high\s+st(reet)?$/i; - - # Disambiguations required for BR5 - $town .= ', BR5' if $string =~ /^kelsey\s+r(?:oa)?d$/i; - $town = 'BR5 Bromley' if $string =~ /^leith\s+hill$/i; # doesn't like appended BR5 for some reason - - # There has also been a road name change for a section of Ramsden Road + # There has been a road name change for a section of Ramsden Road # (BR5) between Church Hill and Court Road has changed to 'Old Priory # Avenue' - presently entering Old Priory Avenue simply takes the user to # a different Priory Avenue in Petts Wood @@ -37,18 +29,10 @@ sub disambiguate_location { $string = 'Ramsden Road'; $town = ', BR6 0PL'; } - $town .= ', BR5' if $string =~ /^meadway/i; - - # and BR6 - $town .= ', BR6' if $string =~ /^berrylands/i; # White Horse Hill is on boundary with Greenwich, so need a # specific postcode - $town = 'chislehurst, BR7 6DH' if $string =~ /^white\s+horse/i; - - # Mottingham Lane is 90% inside Bromley, but goes outside too and Bing - # defaults to the top end of it. - $town = 'Mottingham Lane, SE9 4RW' if $string =~ /^mottingham\s+lane/i; + $string = 'BR7 6DH' if $string =~ /^white\s+horse/i; $town = '' if $string =~ /orpington/i; @@ -61,6 +45,10 @@ sub disambiguate_location { }; } +sub get_geocoder { + return 'OSM'; # default of Bing gives poor results, let's try overriding. +} + sub example_places { return ( 'BR1 3UH', 'Glebe Rd, Bromley' ); } diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index c3185ea05..9541f2601 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -45,15 +45,6 @@ sub country { return ''; } -=head1 problems_clause - -Returns a hash for a query to be used by problems (and elsewhere in joined -queries) to restrict results for a cobrand. - -=cut - -sub problems_clause {} - =head1 problems Returns a ResultSet of Problems, restricted to a subset if we're on a cobrand @@ -66,20 +57,20 @@ sub problems { return $self->{c}->model('DB::Problem'); } -=head1 site_restriction +=head1 body_restriction -Return a site key and a hash of extra query parameters if the cobrand uses a -subset of the FixMyStreet data. Parameter is any extra data the cobrand needs. -Returns a site key of 0 and an empty hash if the cobrand uses all the data. +Return an extra query parameter to restrict reports to those sent to a +particular body. =cut -sub site_restriction { return {}; } +sub body_restriction {} + sub site_key { return 0; } =head2 restriction -Return a restriction to pull out data saved while using the cobrand site. +Return a restriction to data saved while using this specific cobrand site. =cut @@ -117,7 +108,8 @@ sub base_url { FixMyStreet->config('BASE_URL') } =head2 base_url_for_report Return the base url for a report (might be different in a two-tier county, but -most of the time will be same as base_url). +most of the time will be same as base_url). Report may be an object, or a +hashref. =cut @@ -921,4 +913,48 @@ sub jurisdiction_id_example { return $self->moniker; } +=head2 body_details_data + +Returns a list of bodies to create with ensure_body. These +are mostly just passed to ->find_or_create, but there is some +pre-processing so that you can enter: + + area_id => 123, + parent => 'Big Town', + +instead of + + body_areas => [ { area_id => 123 } ], + parent => { name => 'Big Town' }, + +For example: + + return ( + { + name => 'Big Town', + }, + { + name => 'Small town', + parent => 'Big Town', + area_id => 1234, + }, + + +=cut + +sub body_details_data { + return (); +} + +=head2 contact_details_data + +Returns a list of contact_data to create with setup_contacts. +See Zurich for an example. + +=cut + +sub contact_details_data { + return () +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/EastSussex.pm b/perllib/FixMyStreet/Cobrand/EastSussex.pm index 5aabae449..2ba3a4f70 100644 --- a/perllib/FixMyStreet/Cobrand/EastSussex.pm +++ b/perllib/FixMyStreet/Cobrand/EastSussex.pm @@ -48,16 +48,16 @@ Can run with a script or command line like: =cut use constant POTHOLE_SIZES => [ - {'key' => ['Blank'], 'name' => ['--']}, - {'key' => ['golf'], 'name' => ['Golf ball sized']}, - {'key' => ['tennis'], 'name' => ['Tennis ball sized']}, + {'key' => ['Blank'], 'name' => ['--']}, + {'key' => ['golf'], 'name' => ['Golf ball sized']}, + {'key' => ['tennis'], 'name' => ['Tennis ball sized']}, {'key' => ['football'], 'name' => ['Football sized']}, {'key' => ['larger'], 'name' => ['Larger']} ]; use constant POTHOLE_DICT => { map { - @{ $_->{key} }, + @{ $_->{key} }, @{ $_->{name} }, } @{ POTHOLE_SIZES() }, }; @@ -86,7 +86,7 @@ sub temp_update_potholes_contact { 'description' => 'Size of the pothole?', 'required' => 'true', 'datatype' => 'singlevaluelist', - 'datatype_description' => {}, + 'datatype_description' => {}, 'values' => { 'value' => $self->POTHOLE_SIZES, }, @@ -123,5 +123,10 @@ sub send_questionnaires { return 0; } +sub contact_email { + my $self = shift; + return join( '@', 'highways', 'eastsussex.gov.uk' ); +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Greenwich.pm b/perllib/FixMyStreet/Cobrand/Greenwich.pm index 18b0c2f5e..7535a34bf 100644 --- a/perllib/FixMyStreet/Cobrand/Greenwich.pm +++ b/perllib/FixMyStreet/Cobrand/Greenwich.pm @@ -44,4 +44,9 @@ sub pin_colour { return 'yellow'; } +sub contact_email { + my $self = shift; + return join( '@', 'fixmystreet', 'royalgreenwich.gov.uk' ); +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm index b9d48a95c..c78ae5e09 100644 --- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm +++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm @@ -103,4 +103,9 @@ sub pin_colour { sub on_map_default_status { return 'open'; } +sub contact_email { + my $self = shift; + return join( '@', 'highway.enquiries', 'oxfordshire.gov.uk' ); +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/SeeSomething.pm b/perllib/FixMyStreet/Cobrand/SeeSomething.pm index 75f1c32e8..9ae15fd8d 100644 --- a/perllib/FixMyStreet/Cobrand/SeeSomething.pm +++ b/perllib/FixMyStreet/Cobrand/SeeSomething.pm @@ -10,14 +10,9 @@ sub council_name { return 'See Something Say Something'; } sub council_url { return 'seesomething'; } sub area_types { [ 'MTD' ] } -sub site_restriction { +sub body_restriction { my $self = shift; - return { bodies_str => { IN => $self->council_id } }; -} - -sub problems_clause { - my $self = shift; - return { bodies_str => { IN => $self->council_id } }; + return $self->council_id; } sub area_check { diff --git a/perllib/FixMyStreet/Cobrand/Stevenage.pm b/perllib/FixMyStreet/Cobrand/Stevenage.pm index 1de44a50d..5e2f07341 100644 --- a/perllib/FixMyStreet/Cobrand/Stevenage.pm +++ b/perllib/FixMyStreet/Cobrand/Stevenage.pm @@ -29,5 +29,10 @@ sub default_map_zoom { return 3; } sub users_can_hide { return 1; } +sub contact_email { + my $self = shift; + return join( '@', 'csc', 'stevenage.gov.uk' ); +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index e653ae522..074da0915 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -22,10 +22,11 @@ sub path_to_web_templates { ]; } -sub site_restriction { +sub body_restriction { my $self = shift; - return { bodies_str => sprintf('%d', $self->council_id) }; + return $self->council_id; } + sub site_key { my $self = shift; return $self->council_url; @@ -35,23 +36,9 @@ sub restriction { return { cobrand => shift->moniker }; } -# Different function to site_restriction due to two-tier use -sub problems_clause { - my $self = shift; - - if ($self->is_two_tier) { - return { bodies_str => { - like => ('%' . $self->council_id . '%') - }}; - } - else { - return { bodies_str => sprintf('%d', $self->council_id) }; - } -} - sub problems { my $self = shift; - return $self->{c}->model('DB::Problem')->search( $self->problems_clause ); + return $self->{c}->model('DB::Problem')->to_body($self->council_id); } sub base_url { @@ -122,8 +109,15 @@ sub recent_photos { # Returns true if the cobrand owns the problem. sub owns_problem { my ($self, $report) = @_; - my $bodies = $report->bodies; - my %areas = map { %{$_->areas} } values %$bodies; + my @bodies; + if (ref $report eq 'HASH') { + return unless $report->{bodies_str}; + @bodies = split /,/, $report->{bodies_str}; + @bodies = FixMyStreet::App->model('DB::Body')->search({ id => \@bodies })->all; + } else { # Object + @bodies = values %{$report->bodies}; + } + my %areas = map { %{$_->areas} } @bodies; return $areas{$self->council_id} ? 1 : undef; } diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index c64fe1177..6da3e566c 100644 --- a/perllib/FixMyStreet/Cobrand/Zurich.pm +++ b/perllib/FixMyStreet/Cobrand/Zurich.pm @@ -5,9 +5,12 @@ use DateTime; use POSIX qw(strcoll); use RABX; use Scalar::Util 'blessed'; +use DateTime::Format::Pg; use strict; use warnings; +use feature 'say'; +use utf8; =head1 NAME @@ -105,8 +108,11 @@ sub prettify_dt { sub zurich_closed_states { my $states = { 'fixed - council' => 1, - 'closed' => 1, + 'closed' => 1, # extern 'hidden' => 1, + 'investigating' => 1, # wish + 'unable to fix' => 1, # jurisdiction unknown + 'partial' => 1, # not contactable }; return wantarray ? keys %{ $states } : $states; @@ -117,6 +123,37 @@ sub problem_is_closed { return exists $self->zurich_closed_states->{ $problem->state } ? 1 : 0; } +sub zurich_public_response_states { + my $states = { + 'fixed - council' => 1, + 'closed' => 1, # extern + 'unable to fix' => 1, # jurisdiction unknown + }; + + return wantarray ? keys %{ $states } : $states; +} + +sub zurich_user_response_states { + my $states = { + 'hidden' => 1, + 'investigating' => 1, # wish + 'partial' => 1, # not contactable + }; + + return wantarray ? keys %{ $states } : $states; +} + +sub problem_has_public_response { + my ($self, $problem) = @_; + return exists $self->zurich_public_response_states->{ $problem->state } ? 1 : 0; +} + +sub problem_has_user_response { + my ($self, $problem) = @_; + my $state_matches = exists $self->zurich_user_response_states->{ $problem->state } ? 1 : 0; + return $state_matches && $problem->get_extra_metadata('public_response'); +} + sub problem_as_hashref { my $self = shift; my $problem = shift; @@ -132,16 +169,35 @@ sub problem_as_hashref { $hashref->{title} = _('This report is awaiting moderation.'); $hashref->{state} = 'submitted'; $hashref->{state_t} = _('Submitted'); + $hashref->{banner_id} = 'closed'; } else { if ( $problem->state eq 'confirmed' ) { $hashref->{state} = 'open'; $hashref->{state_t} = _('Open'); + $hashref->{banner_id} = 'closed'; + } elsif ( $problem->state eq 'closed' ) { + $hashref->{state} = 'extern'; # is this correct? + $hashref->{banner_id} = 'closed'; + $hashref->{state_t} = _('Extern'); + } elsif ( $problem->state eq 'unable to fix' ) { + $hashref->{state} = 'jurisdiction unknown'; # is this correct? + $hashref->{state_t} = _('Jurisdiction Unknown'); + $hashref->{banner_id} = 'fixed'; # green + } elsif ( $problem->state eq 'partial' ) { + $hashref->{state} = 'not contactable'; # is this correct? + $hashref->{state_t} = _('Not contactable'); + # no banner_id as hidden + } elsif ( $problem->state eq 'investigating' ) { + $hashref->{state} = 'wish'; # is this correct? + $hashref->{state_t} = _('Wish'); } elsif ( $problem->is_fixed ) { $hashref->{state} = 'closed'; + $hashref->{banner_id} = 'fixed'; $hashref->{state_t} = _('Closed'); } elsif ( $problem->state eq 'in progress' || $problem->state eq 'planned' ) { $hashref->{state} = 'in progress'; $hashref->{state_t} = _('In progress'); + $hashref->{banner_id} = 'progress'; } } @@ -275,13 +331,46 @@ sub get_or_check_overdue { return $self->overdue($problem); } +=head1 C<set_problem_state> + +If the state has changed, sets the state and calls C::Admin's C<log_edit> action. +If the state hasn't changed, defers to update_admin_log (to update time_spent if any). + +Returns either undef or the AdminLog entry created. + +=cut + sub set_problem_state { my ($self, $c, $problem, $new_state) = @_; - return if $new_state eq $problem->state; + return $self->update_admin_log($c, $problem) if $new_state eq $problem->state; $problem->state( $new_state ); $c->forward( 'log_edit', [ $problem->id, 'problem', "state change to $new_state" ] ); } +=head1 C<update_admin_log> + +Calls C::Admin's C<log_edit> if either a) text is provided, or b) there has +been time_spent on the task. As set_problem_state will already call log_edit +if required, don't call this as well. + +Returns either undef or the AdminLog entry created. + +=cut + +sub update_admin_log { + my ($self, $c, $problem, $text) = @_; + + my $time_spent = ( ($c->get_param('time_spent') // 0) + 0 ); + $c->set_param('time_spent' => 0); # explicitly zero this to avoid duplicates + + if (!$text) { + return unless $time_spent; + $text = "Logging time_spent"; + } + + $c->forward( 'log_edit', [ $problem->id, 'problem', $text, $time_spent ] ); +} + # Specific administrative displays sub admin_pages { @@ -289,23 +378,25 @@ sub admin_pages { my $c = $self->{c}; my $type = $c->stash->{admin_type}; + my $pages = { 'summary' => [_('Summary'), 0], 'reports' => [_('Reports'), 2], 'report_edit' => [undef, undef], 'update_edit' => [undef, undef], + 'stats' => [_('Stats'), 4], }; return $pages if $type eq 'sdm'; $pages = { %$pages, 'bodies' => [_('Bodies'), 1], 'body' => [undef, undef], + 'templates' => [_('Templates'), 2], }; return $pages if $type eq 'dm'; $pages = { %$pages, 'users' => [_('Users'), 3], - 'stats' => [_('Stats'), 4], 'user_edit' => [undef, undef], }; return $pages if $type eq 'super'; @@ -448,7 +539,7 @@ sub admin_report_edit { } - # If super or sdm check that the token is correct before proceeding + # If super or dm check that the token is correct before proceeding if ( ($type eq 'super' || $type eq 'dm') && $c->get_param('submit') ) { $c->forward('check_token'); } @@ -468,6 +559,7 @@ sub admin_report_edit { } } + # Problem updates upon submission if ( ($type eq 'super' || $type eq 'dm') && $c->get_param('submit') ) { $problem->set_extra_metadata('publish_photo' => $c->get_param('publish_photo') || 0 ); @@ -488,10 +580,43 @@ sub admin_report_edit { my $internal_note_text = ""; # Workflow things + # + # Note that 2 types of email may be sent + # 1) _admin_send_email() sends an email to the *user*, if their email is confirmed + # + # 2) setting $problem->whensent(undef) may make it eligible for generating an email + # to the body (internal or external). See DBRS::Problem->send_reports for Zurich- + # specific categories which are eligible for this. + # + # It looks like both of these will do: + # a) TT processing of [% ... %] directives, in FMS::App->send_email(_cron) + # b) pseudo-PHP substitution of <?=$values['name']?> which is done as + # naive substitution + # commonlib mySociety::Email + # + # So it makes sense to add new parameters as the more powerful TT (a). + my $redirect = 0; - my $new_cat = $c->get_param('category'); - if ( $new_cat && $new_cat ne $problem->category ) { - my $cat = $c->model('DB::Contact')->search( { category => $c->get_param('category') } )->first; + my $new_cat = $c->get_param('category') || ''; + my $state = $c->get_param('state') || ''; + my $oldstate = $problem->state; + + my $closure_states = $self->zurich_closed_states; + delete $closure_states->{'fixed - council'}; # may not be needed? + + my $old_closure_state = $problem->get_extra_metadata('closure_status'); + + # update the public update from DM + if (my $update = $c->get_param('status_update')) { + $problem->set_extra_metadata(public_response => $update); + } + + if ( + ($state eq 'confirmed') + && $new_cat + && $new_cat ne $problem->category + ) { + my $cat = $c->model('DB::Contact')->search({ category => $c->get_param('category') } )->first; my $old_cat = $problem->category; $problem->category( $new_cat ); $problem->external_body( undef ); @@ -499,7 +624,25 @@ sub admin_report_edit { $problem->whensent( undef ); $problem->set_extra_metadata(changed_category => 1); $internal_note_text = "Weitergeleitet von $old_cat an $new_cat"; + $self->update_admin_log($c, $problem, "Changed category from $old_cat to $new_cat"); $redirect = 1 if $cat->body_id ne $body->id; + } elsif ( $closure_states->{$state} and + ( $oldstate ne 'planned' ) + || (($old_closure_state ||'') ne $state)) + { + # for these states + # - closed (Extern) + # - investigating (Wish) + # - hidden + # - partial (Not contactable) + # - unable to fix (Jurisdiction unknown) + # we divert to planned (Rueckmeldung ausstehend) and set closure_status to the requested state + # From here, the DM can reply to the user, triggering the setting of problem to correct state + $problem->set_extra_metadata( closure_status => $state ); + $self->set_problem_state($c, $problem, 'planned'); + $state = 'planned'; + $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); + } elsif ( my $subdiv = $c->get_param('body_subdivision') ) { $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); $self->set_problem_state($c, $problem, 'in progress'); @@ -507,31 +650,82 @@ sub admin_report_edit { $problem->bodies_str( $subdiv ); $problem->whensent( undef ); $redirect = 1; - } elsif ( my $external = $c->get_param('body_external') ) { - $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); - $self->set_problem_state($c, $problem, 'closed'); - $problem->set_extra_metadata_if_undefined( closed_overdue => $self->overdue( $problem ) ); - $problem->external_body( $external ); - $problem->whensent( undef ); - _admin_send_email( $c, 'problem-external.txt', $problem ); - $redirect = 1; } else { - if (my $state = $c->get_param('state')) { + if ($state) { - if ($problem->state eq 'unconfirmed' and $state ne 'unconfirmed') { + if ($oldstate eq 'unconfirmed' and $state ne 'unconfirmed') { # only set this for the first state change $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); } - $self->set_problem_state($c, $problem, $state); + $self->set_problem_state($c, $problem, $state) + unless $closure_states->{$state}; + # we'll defer to 'planned' clause below to change the state + } + } + + if ($problem->state eq 'planned') { + # Rueckmeldung ausstehend + # override $state from the metadata set above + $state = $problem->get_extra_metadata('closure_status') || ''; + my ($moderated, $closed) = (0, 0); - if ($self->problem_is_closed($problem)) { - $problem->set_extra_metadata_if_undefined( closed_overdue => $self->overdue( $problem ) ); + if ($state eq 'hidden' && $c->get_param('publish_response') ) { + _admin_send_email( $c, 'problem-rejected.txt', $problem ); + + $self->set_problem_state($c, $problem, $state); + $moderated++; + $closed++; + } + elsif ($state =~/^(closed|investigating)$/) { # Extern | Wish + $moderated++; + # Nested if instead of `and` because in these cases, we *don't* + # want to close unless we have body_external (so we don't want + # the final elsif clause below to kick in on publish_response) + if (my $external = $c->get_param('body_external')) { + my $external_body = $c->model('DB::Body')->find($external) + or die "Body $external not found"; + $problem->external_body( $external ); + } + if ($problem->external_body && $c->get_param('publish_response')) { + $problem->whensent( undef ); + $self->set_problem_state($c, $problem, $state); + my $template = ($state eq 'investigating') ? 'problem-wish.txt' : 'problem-external.txt'; + _admin_send_email( $c, $template, $problem ); + $redirect = 0; + $closed++; } - if ( $state eq 'hidden' && $c->get_param('send_rejected_email') ) { - _admin_send_email( $c, 'problem-rejected.txt', $problem ); + # set the external_message in extra, so that it can be edited again + if ( my $external_message = $c->get_param('external_message') ) { + $problem->set_extra_metadata( external_message => $external_message ); } + # else should really return a message here + } + elsif ($c->get_param('publish_response')) { + # otherwise we only set the state if publish_response is set + # + + # if $state wasn't set, then we are simply closing the message as fixed + $state ||= 'fixed - council'; + _admin_send_email( $c, 'problem-closed.txt', $problem ); + $redirect = 0; + $moderated++; + $closed++; + } + + if ($moderated) { + $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); } + + if ($closed) { + # set to either the closure_status from metadata or 'fixed - council' as above + $self->set_problem_state($c, $problem, $state); + $problem->set_extra_metadata_if_undefined( closed_overdue => $self->overdue( $problem ) ); + $problem->unset_extra_metadata('closure_status'); + } + } + else { + $problem->unset_extra_metadata('closure_status'); } $problem->title( $c->get_param('title') ) if $c->get_param('title'); @@ -539,21 +733,42 @@ sub admin_report_edit { $problem->latitude( $c->get_param('latitude') ); $problem->longitude( $c->get_param('longitude') ); - # Final, public, Update from DM - if (my $update = $c->get_param('status_update')) { - $problem->set_extra_metadata(public_response => $update); - if ($c->get_param('publish_response')) { - $self->set_problem_state($c, $problem, 'fixed - council'); - $problem->set_extra_metadata( closed_overdue => $self->overdue( $problem ) ); - _admin_send_email( $c, 'problem-closed.txt', $problem ); - } + # send external_message if provided and state is *now* Wish|Extern + # e.g. was already, or was set in the Rueckmeldung ausstehend clause above. + if ( my $external_message = $c->get_param('external_message') + and $problem->state =~ /^(closed|investigating)$/) + { + my $external = $problem->external_body; + my $external_body = $c->model('DB::Body')->find($external) + or die "Body $external not found"; + + $problem->set_extra_metadata_if_undefined( moderated_overdue => $self->overdue( $problem ) ); + # Create a Comment on this Problem with the content of the external message. + # NB this isn't directly shown anywhere, but is for logging purposes. + $problem->add_to_comments( { + text => ( + sprintf '(%s %s) %s', + $state eq 'closed' ? + _('Forwarded to external body') : + _('Forwarded wish to external body'), + $external_body->name, + $external_message, + ), + user => $c->user->obj, + state => 'hidden', # seems best fit, should not be shown publicly + mark_fixed => 0, + anonymous => 1, + extra => { is_internal_note => 1, is_external_message => 1 }, + } ); + # set the external_message in extra, so that it will be picked up + # later by send-reports + $problem->set_extra_metadata( external_message => $external_message ); } - $problem->lastupdate( \'ms_current_timestamp()' ); + $problem->lastupdate( \'current_timestamp' ); $problem->update; - $c->stash->{status_message} = - '<p><em>' . _('Updated!') . '</em></p>'; + $c->stash->{status_message} = '<p class="message-updated">' . _('Updated!') . '</p>'; # do this here otherwise lastupdate and confirmed times # do not display correctly (reloads problem from database, including @@ -572,14 +787,21 @@ sub admin_report_edit { } ); } - if ( $redirect ) { - $c->detach('index'); + # Just update if time_spent still hasn't been logged + # (this will only happen if no other update_admin_log has already been called) + $self->update_admin_log($c, $problem); + + if ( $redirect and $type eq 'dm' ) { + # only redirect for DM + $c->stash->{status_message} ||= '<p class="message-updated">' . _('Updated!') . '</p>'; + $c->go('index'); } $c->stash->{updates} = [ $c->model('DB::Comment') ->search( { problem_id => $problem->id }, { order_by => 'created' } ) ->all ]; + $self->stash_states($problem); return 1; } @@ -588,15 +810,32 @@ sub admin_report_edit { # Has cut-down edit template for adding update and sending back up only $c->stash->{template} = 'admin/report_edit-sdm.html'; - if ($c->get_param('send_back')) { + if ($c->get_param('send_back') or $c->get_param('not_contactable')) { + # SDM can send back a report either to be assigned to a different + # subdivision, or because the customer was not contactable. + # We handle these in the same way but with different statuses. + $c->forward('check_token'); + my $not_contactable = $c->get_param('not_contactable'); + $problem->bodies_str( $body->parent->id ); - $self->set_problem_state($c, $problem, 'confirmed'); + if ($not_contactable) { + # we can't directly set state, but mark the closure_status for DM to confirm. + $self->set_problem_state($c, $problem, 'planned'); + $problem->set_extra_metadata( closure_status => 'partial'); + } + else { + $self->set_problem_state($c, $problem, 'confirmed'); + } $problem->update; - # log here + $c->forward( 'log_edit', [ $problem->id, 'problem', + $not_contactable ? + _('Customer not contactable') + : _('Sent report back') ] ); + # Make sure the problem's time_spent is updated + $self->update_admin_log($c, $problem); $c->res->redirect( '/admin/summary' ); - } elsif ($c->get_param('submit')) { $c->forward('check_token'); @@ -621,8 +860,10 @@ sub admin_report_edit { anonymous => 1, } ); } + # Make sure the problem's time_spent is updated + $self->update_admin_log($c, $problem); - $c->stash->{status_message} = '<p><em>' . _('Updated!') . '</em></p>'; + $c->stash->{status_message} = '<p class="message-updated">' . _('Updated!') . '</p>'; # If they clicked the no more updates button, we're done. if ($c->get_param('no_more_updates')) { @@ -639,14 +880,113 @@ sub admin_report_edit { ->search( { problem_id => $problem->id }, { order_by => 'created' } ) ->all ]; + $self->stash_states($problem); return 1; } + $self->stash_states($problem); return 0; } +sub stash_states { + my ($self, $problem) = @_; + my $c = $self->{c}; + + # current problem state affects which states are visible in dropdowns + my @states = ( + { + # Erfasst + state => 'unconfirmed', + trans => _('Submitted'), + unconfirmed => 1, + hidden => 1, + }, + { + # Aufgenommen + state => 'confirmed', + trans => _('Open'), + unconfirmed => 1, + }, + { + # Unsichtbar (hidden) + state => 'hidden', + trans => _('Hidden'), + unconfirmed => 1, + hidden => 1, + }, + { + # Extern + state => 'closed', + trans => _('Extern'), + }, + { + # Zustaendigkeit unbekannt + state => 'unable to fix', + trans => _('Jurisdiction unknown'), + }, + { + # Wunsch (hidden) + state => 'investigating', + trans => _('Wish'), + }, + { + # Nicht kontaktierbar (hidden) + state => 'partial', + trans => _('Not contactable'), + }, + ); + my %state_trans = map { $_->{state} => $_->{trans} } @states; + + my $state = $problem->state; + + # Rueckmeldung ausstehend may also indicate the status it's working towards. + push @states, do { + if ($state eq 'planned' and my $closure_status = $problem->get_extra_metadata('closure_status')) { + { + state => $closure_status, + trans => sprintf '%s (%s)', _('Planned'), $state_trans{$closure_status}, + }; + } + else { + { + state => 'planned', + trans => _('Planned'), + }; + } + }; + + if ($state eq 'in progress') { + push @states, { + state => 'in progress', + trans => _('In progress'), + }; + } + elsif ($state eq 'fixed - council') { + push @states, { + state => 'fixed - council', + trans => _('Closed'), + }; + } + elsif ($state =~/^(hidden|unconfirmed)$/) { + @states = grep { $_->{$state} } @states; + } + $c->stash->{states} = \@states; + $c->stash->{states_trans} = { map { $_->{state} => $_->{trans} } @states }; # [% states_trans.${problem.state} %] + + # stash details about the public response + $c->stash->{default_public_response} = "\nFreundliche Grüsse\n\nIhre Stadt Zürich\n"; + $c->stash->{show_publish_response} = + ($problem->state eq 'planned'); +} + +=head2 _admin_send_email + +Send an email to the B<user> who logged the problem, if their email address is confirmed. + +=cut + sub _admin_send_email { my ( $c, $template, $problem ) = @_; @@ -665,9 +1005,36 @@ sub _admin_send_email { to => [ $to ], url => $c->uri_for_email( $problem->url ), from => [ $sender, $sender_name ], + problem => $problem, } ); } +sub munge_sendreport_params { + my ($self, $c, $row, $h, $params) = @_; + if ($row->state =~ /^(closed|investigating)$/ && $row->get_extra_metadata('publish_photo')) { + # we attach images to reports sent to external bodies + my $photoset = $row->get_photoset($c); + my @images = $photoset->all_images + or return; + my $index = 0; + my $id = $row->id; + my @attachments = map { + my $i = $index++; + { + body => $_->[1], + attributes => { + filename => "$id.$i.jpeg", + content_type => 'image/jpeg', + encoding => 'base64', + # quoted-printable ends up with newlines corrupting binary data + name => "$id.$i.jpeg", + }, + } + } @images; + $params->{attachments} = \@attachments; + } +} + sub admin_fetch_all_bodies { my ( $self, @bodies ) = @_; @@ -718,7 +1085,10 @@ sub admin_stats { if ($y && $m) { $c->stash->{start_date} = DateTime->new( year => $y, month => $m, day => 1 ); $c->stash->{end_date} = $c->stash->{start_date} + DateTime::Duration->new( months => 1 ); - $date_params{created} = { '>=', $c->stash->{start_date}, '<', $c->stash->{end_date} }; + $date_params{created} = { + '>=', DateTime::Format::Pg->format_datetime($c->stash->{start_date}), + '<', DateTime::Format::Pg->format_datetime($c->stash->{end_date}), + }; } my %params = ( @@ -730,6 +1100,8 @@ sub admin_stats { my $problems = $c->model('DB::Problem')->search( {%date_params}, { + join => 'admin_log_entries', + distinct => 1, columns => [ 'id', 'created', 'latitude', 'longitude', @@ -741,10 +1113,11 @@ sub admin_stats { 'whensent', 'lastupdate', 'service', 'extra', - ], + { sum_time_spent => { sum => 'admin_log_entries.time_spent' } }, + ] } ); - my $body = "Report ID,Created,Sent to Agency,Last Updated,E,N,Category,Status,UserID,External Body,Title,Detail,Media URL,Interface Used,Council Response\n"; + my $body = "Report ID,Created,Sent to Agency,Last Updated,E,N,Category,Status,UserID,External Body,Time Spent,Title,Detail,Media URL,Interface Used,Council Response\n"; require Text::CSV; my $csv = Text::CSV->new({ binary => 1 }); while ( my $report = $problems->next ) { @@ -759,7 +1132,10 @@ sub admin_stats { # replace newlines with HTML <br/> element $detail =~ s{\r?\n}{ <br/> }g; - $public_response =~ s{\r?\n}{ <br/> }g; + $public_response =~ s{\r?\n}{ <br/> }g if $public_response; + + # Assemble photo URL, if report has a photo + my $media_url = $report->get_photo_params->{url} ? ($c->cobrand->base_url . $report->get_photo_params->{url}) : ''; my @columns = ( $report->id, @@ -769,9 +1145,10 @@ sub admin_stats { $report->local_coords, $report->category, $report->state, $report->user_id, $body_name, + $report->get_column('sum_time_spent') || 0, $report->title, $detail, - $c->cobrand->base_url . $report->get_photo_params->{url}, + $media_url, $report->service || 'Web interface', $public_response, ); @@ -850,4 +1227,111 @@ sub admin_stats { return 1; } +sub problem_confirm_email_extras { + my ($self, $report) = @_; + my $confirmed_reports = $report->user->problems->search({ + extra => { like => '%email_confirmed%' }, + })->count; + + $self->{c}->stash->{email_confirmed} = $confirmed_reports; +} + +sub body_details_data { + return ( + { + name => 'Stadt Zurich' + }, + { + name => 'Elektrizitäwerk Stadt Zürich', + parent => 'Stadt Zurich', + area_id => 423017, + }, + { + name => 'ERZ Entsorgung + Recycling Zürich', + parent => 'Stadt Zurich', + area_id => 423017, + }, + { + name => 'Fachstelle Graffiti', + parent => 'Stadt Zurich', + area_id => 423017, + }, + { + name => 'Grün Stadt Zürich', + parent => 'Stadt Zurich', + area_id => 423017, + }, + { + name => 'Tiefbauamt Stadt Zürich', + parent => 'Stadt Zurich', + area_id => 423017, + }, + { + name => 'Dienstabteilung Verkehr', + parent => 'Stadt Zurich', + area_id => 423017, + }, + ); +} + +sub contact_details_data { + return ( + { + category => 'Beleuchtung/Uhren', + body_name => 'Elektrizitätswerk Stadt Zürich', + fields => [ + { + code => 'strasse', + description => 'Strasse', + datatype => 'string', + required => 'yes', + }, + { + code => 'haus_nr', + description => 'Haus-Nr.', + datatype => 'string', + }, + { + code => 'mast_nr', + description => 'Mast-Nr.', + datatype => 'string', + } + ], + }, + { + category => 'Brunnen/Hydranten', + # body_name ??? + fields => [ + { + code => 'hydranten_nr', + description => 'Hydranten-Nr.', + datatype => 'string', + }, + ], + }, + { + category => "Grünflächen/Spielplätze", + body_name => 'Grün Stadt Zürich', + rename_from => "Tiere/Grünflächen", + }, + { + category => 'Spielplatz/Sitzbank', + body_name => 'Grün Stadt Zürich', + delete => 1, + }, + ); +} + +sub contact_details_data_body_default { + my ($self) = @_; + # temporary measure to assign un-bodied contacts to parent + # (this isn't at all how things will be setup in live, but is + # handy during dev.) + return $self->{c}->model('DB::Body')->find({ name => 'Stadt Zurich' }); +} + +sub reports_per_page { return 20; } + +sub singleton_bodies_str { 1 } + 1; diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm index 41bc3100a..d60915cfc 100644 --- a/perllib/FixMyStreet/DB/Result/AdminLog.pm +++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm @@ -29,13 +29,16 @@ __PACKAGE__->add_columns( "whenedited", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, "user_id", { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "reason", { data_type => "text", default_value => "", is_nullable => 0 }, + "time_spent", + { data_type => "integer", default_value => "0", is_nullable => 0 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->belongs_to( @@ -51,7 +54,7 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:58:54 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:okGiaKaVYaTrlz0LCV01vA +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:RCi1FEwb9T2MZ2X+QOTTUA 1; diff --git a/perllib/FixMyStreet/DB/Result/Alert.pm b/perllib/FixMyStreet/DB/Result/Alert.pm index c64cb2ff4..35cce8368 100644 --- a/perllib/FixMyStreet/DB/Result/Alert.pm +++ b/perllib/FixMyStreet/DB/Result/Alert.pm @@ -37,8 +37,9 @@ __PACKAGE__->add_columns( "whensubscribed", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, "whendisabled", { data_type => "timestamp", is_nullable => 1 }, @@ -64,8 +65,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:d9yIFiTGtbtFaULXZNKstQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:5RNyB430T8PqtFlmGV/MUg # You can replace this text with custom code or comments, and it will be preserved on regeneration @@ -106,7 +107,7 @@ sub confirm { sub disable { my $self = shift; - $self->whendisabled( \'ms_current_timestamp()' ); + $self->whendisabled( \'current_timestamp' ); $self->update; return 1; diff --git a/perllib/FixMyStreet/DB/Result/AlertSent.pm b/perllib/FixMyStreet/DB/Result/AlertSent.pm index 422e010a9..83043a33b 100644 --- a/perllib/FixMyStreet/DB/Result/AlertSent.pm +++ b/perllib/FixMyStreet/DB/Result/AlertSent.pm @@ -18,8 +18,9 @@ __PACKAGE__->add_columns( "whenqueued", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, ); __PACKAGE__->belongs_to( @@ -30,8 +31,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2013-09-10 17:11:54 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:COwsprqRSNZS1IxJrPYgMQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:/+Vodu8VJxJ0EY9P3Qjjjw # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm index 0c1046cd8..a2e004c6a 100644 --- a/perllib/FixMyStreet/DB/Result/Body.pm +++ b/perllib/FixMyStreet/DB/Result/Body.pm @@ -18,12 +18,6 @@ __PACKAGE__->add_columns( is_nullable => 0, sequence => "body_id_seq", }, - "name", - { data_type => "text", is_nullable => 0 }, - "external_url", - { data_type => "text", is_nullable => 1 }, - "parent", - { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "endpoint", { data_type => "text", is_nullable => 1 }, "jurisdiction", @@ -42,8 +36,14 @@ __PACKAGE__->add_columns( { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "send_extended_statuses", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0 }, + "parent", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "deleted", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "external_url", + { data_type => "text", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->has_many( @@ -87,6 +87,12 @@ __PACKAGE__->belongs_to( }, ); __PACKAGE__->has_many( + "response_templates", + "FixMyStreet::DB::Result::ResponseTemplate", + { "foreign.body_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); +__PACKAGE__->has_many( "user_body_permissions", "FixMyStreet::DB::Result::UserBodyPermission", { "foreign.body_id" => "self.id" }, @@ -100,8 +106,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-29 13:54:07 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:PhUeFDRLSQVMk7Sts5K6MQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-02-19 16:13:43 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:d6GuQm8vrNmCc4NWw58srA sub url { my ( $self, $c, $args ) = @_; diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm index 836462ed5..0caaa8968 100644 --- a/perllib/FixMyStreet/DB/Result/Comment.pm +++ b/perllib/FixMyStreet/DB/Result/Comment.pm @@ -31,8 +31,9 @@ __PACKAGE__->add_columns( "created", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, "confirmed", { data_type => "timestamp", is_nullable => 1 }, @@ -88,8 +89,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:59:43 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:08AtJ6CZFyUe7qKMF50MHg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZR+YNA1Jej3s+8mr52iq6Q # __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); @@ -144,7 +145,7 @@ sub confirm { my $self = shift; $self->state( 'confirmed' ); - $self->confirmed( \'ms_current_timestamp()' ); + $self->confirmed( \'current_timestamp' ); } =head2 get_photo_params diff --git a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm index 08d03f94b..d7240cd5d 100644 --- a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm +++ b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm @@ -33,8 +33,9 @@ __PACKAGE__->add_columns( "created", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, ); __PACKAGE__->set_primary_key("id"); @@ -46,7 +47,7 @@ __PACKAGE__->belongs_to( { is_deferrable => 0, join_type => "LEFT", - on_delete => "NO ACTION", + on_delete => "CASCADE,", on_update => "NO ACTION", }, ); @@ -54,12 +55,12 @@ __PACKAGE__->belongs_to( "problem", "FixMyStreet::DB::Result::Problem", { id => "problem_id" }, - { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, + { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" }, ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:59:43 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:yR1Vi7cJQrX67dFwAcJW6w +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:DBtGjCJykDtLnGtkj638eA # You can replace this text with custom code or comments, and it will be preserved on regeneration diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm index bed2f160a..3b7f8bcfd 100644 --- a/perllib/FixMyStreet/DB/Result/Problem.pm +++ b/perllib/FixMyStreet/DB/Result/Problem.pm @@ -53,8 +53,9 @@ __PACKAGE__->add_columns( "created", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, "confirmed", { data_type => "timestamp", is_nullable => 1 }, @@ -71,8 +72,9 @@ __PACKAGE__->add_columns( "lastupdate", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, "whensent", { data_type => "timestamp", is_nullable => 1 }, @@ -102,6 +104,8 @@ __PACKAGE__->add_columns( { data_type => "integer", default_value => 0, is_nullable => 1 }, "subcategory", { data_type => "text", is_nullable => 1 }, + "bodies_missing", + { data_type => "text", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->has_many( @@ -130,8 +134,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2014-07-31 15:57:02 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:EvD4sS1mdJJyI1muZ4TrCw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Go+T9oFRfwQ1Ag89qPpF/g # Add fake relationship to stored procedure table __PACKAGE__->has_one( @@ -319,10 +323,6 @@ sub visible_states_remove { } } -sub visible_states_add_unconfirmed { - $_[0]->visible_states_add('unconfirmed') -} - =head2 @states = FixMyStreet::DB::Problem::council_states(); @@ -400,7 +400,7 @@ sub check_for_errors { $errors{bodies} = _('No council selected') unless $self->bodies_str - && $self->bodies_str =~ m/^(?:-1|[\d,]+(?:\|[\d,]+)?)$/; + && $self->bodies_str =~ m/^(?:-1|[\d,]+)$/; if ( !$self->name || $self->name !~ m/\S/ ) { $errors{name} = _('Please enter your name'); @@ -441,15 +441,14 @@ sub confirm { return if $self->state eq 'confirmed'; $self->state('confirmed'); - $self->confirmed( \'ms_current_timestamp()' ); + $self->confirmed( \'current_timestamp' ); return 1; } sub bodies_str_ids { my $self = shift; return unless $self->bodies_str; - (my $bodies = $self->bodies_str) =~ s/\|.*$//; - my @bodies = split( /,/, $bodies ); + my @bodies = split( /,/, $self->bodies_str ); return \@bodies; } @@ -480,11 +479,17 @@ sub url { =head2 get_photo_params -Returns a hashref of details of any attached photo for use in templates. +Returns a hashref of details of the attached photo, if any, for use in templates. + +NB: this method doesn't currently support multiple photos gracefully. + +Use get_photoset($c) instead to do the right thing with reports with 0, 1, or more photos. =cut sub get_photo_params { + # use Carp 'cluck'; + # cluck "get_photo_params called"; # TEMPORARY die to make sure I've done right thing with Zurich templates my $self = shift; return FixMyStreet::App::get_photo_params($self, 'id'); } @@ -634,6 +639,25 @@ sub body { return $body; } +=head2 response_templates + +Returns all ResponseTemplates attached to this problem's bodies, in alphabetical +order of title. + +=cut + +sub response_templates { + my $problem = shift; + return FixMyStreet::App->model('DB::ResponseTemplate')->search( + { + body_id => $problem->bodies_str_ids + }, + { + order_by => 'title' + } + ); +} + # returns true if the external id is the council's ref, i.e., useful to publish it # (by way of an example, the barnet send method returns a useful reference when # it succeeds, so that is the ref we should show on the problem report page). @@ -642,8 +666,7 @@ sub body { # Note: this only makes sense when called on a problem that has been sent! sub can_display_external_id { my $self = shift; - if ($self->external_id && $self->send_method_used && - ($self->send_method_used eq 'barnet' || $self->bodies_str =~ /2237/)) { + if ($self->external_id && $self->send_method_used && $self->bodies_str =~ /2237/) { return 1; } return 0; @@ -735,8 +758,8 @@ sub update_from_open311_service_request { { problem_id => $self->id, state => 'confirmed', - created => $updated || \'ms_current_timestamp()', - confirmed => \'ms_current_timestamp()', + created => $updated || \'current_timestamp', + confirmed => \'current_timestamp', text => $status_notes, mark_open => 0, mark_fixed => 0, @@ -789,7 +812,7 @@ sub update_send_failed { $self->update( { send_fail_count => $self->send_fail_count + 1, - send_fail_timestamp => \'ms_current_timestamp()', + send_fail_timestamp => \'current_timestamp', send_fail_reason => $msg } ); } @@ -828,6 +851,27 @@ sub latest_moderation_log_entry { return $self->admin_log_entries->search({ action => 'moderation' }, { order_by => 'id desc' })->first; } +=head2 get_photoset + +Return a PhotoSet object for all photos attached to this field + + my $photoset = $obj->get_photoset( $c ); + print $photoset->num_images; + return $photoset->get_image_data(num => 0, size => 'full'); + +=cut + +sub get_photoset { + my ($self, $c) = @_; + my $class = 'FixMyStreet::App::Model::PhotoSet'; + eval "use $class"; + return $class->new({ + c => $c, + data => $self->photo, + object => $self, + }); +} + __PACKAGE__->has_many( "admin_log_entries", "FixMyStreet::DB::Result::AdminLog", @@ -838,6 +882,18 @@ __PACKAGE__->has_many( } ); +sub get_time_spent { + my $self = shift; + my $admin_logs = $self->admin_log_entries->search({}, + { + group_by => 'object_id', + columns => [ + { sum_time_spent => { sum => 'time_spent' } }, + ] + })->single; + return $admin_logs ? $admin_logs->get_column('sum_time_spent') : 0; +} + # we need the inline_constructor bit as we don't inherit from Moose __PACKAGE__->meta->make_immutable( inline_constructor => 0 ); diff --git a/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm new file mode 100644 index 000000000..48a1ab3ae --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm @@ -0,0 +1,49 @@ +use utf8; +package FixMyStreet::DB::Result::ResponseTemplate; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; +__PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); +__PACKAGE__->table("response_templates"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "response_templates_id_seq", + }, + "body_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "title", + { data_type => "text", is_nullable => 0 }, + "text", + { data_type => "text", is_nullable => 0 }, + "created", + { + data_type => "timestamp", + default_value => \"current_timestamp", + is_nullable => 0, + }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->add_unique_constraint("response_templates_body_id_title_key", ["body_id", "title"]); +__PACKAGE__->belongs_to( + "body", + "FixMyStreet::DB::Result::Body", + { id => "body_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-02-19 16:13:43 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:xzhmxtu0taAnBMZN0HBocw + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; diff --git a/perllib/FixMyStreet/DB/Result/Token.pm b/perllib/FixMyStreet/DB/Result/Token.pm index 0156af137..a60e23839 100644 --- a/perllib/FixMyStreet/DB/Result/Token.pm +++ b/perllib/FixMyStreet/DB/Result/Token.pm @@ -20,15 +20,16 @@ __PACKAGE__->add_columns( "created", { data_type => "timestamp", - default_value => \"ms_current_timestamp()", + default_value => \"current_timestamp", is_nullable => 0, + original => { default_value => \"now()" }, }, ); __PACKAGE__->set_primary_key("scope", "token"); -# Created by DBIx::Class::Schema::Loader v0.07017 @ 2012-03-08 17:19:55 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:+LLZ8P5GXqPetuGyrra2vw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2015-08-13 16:33:38 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HkvzOY5STjOdXN64hxg5NA use mySociety::AuthToken; @@ -42,8 +43,7 @@ Representation of mySociety::AuthToken in the DBIx::Class world. The 'data' value is automatically inflated and deflated in the same way that the AuthToken would do it. 'token' is set to a new random value by default and the -'created' timestamp is achieved using the database function -ms_current_timestamp. +'created' timestamp is achieved using the database function current_timestamp. =cut @@ -55,7 +55,7 @@ sub new { my ( $class, $attrs ) = @_; $attrs->{token} ||= mySociety::AuthToken::random_token(); - $attrs->{created} ||= \'ms_current_timestamp()'; + $attrs->{created} ||= \'current_timestamp'; my $new = $class->next::method($attrs); return $new; diff --git a/perllib/FixMyStreet/DB/ResultSet/Alert.pm b/perllib/FixMyStreet/DB/ResultSet/Alert.pm index 5848265f1..bb1c61141 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Alert.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Alert.pm @@ -14,7 +14,7 @@ sub timeline_created { return $rs->search( { - whensubscribed => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, + whensubscribed => { '>=', \"current_timestamp-'7 days'::interval" }, confirmed => 1, %{ $restriction }, }, @@ -29,7 +29,7 @@ sub timeline_disabled { return $rs->search( { - whendisabled => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, + whendisabled => { '>=', \"current_timestamp-'7 days'::interval" }, %{ $restriction }, }, ); diff --git a/perllib/FixMyStreet/DB/ResultSet/AlertType.pm b/perllib/FixMyStreet/DB/ResultSet/AlertType.pm index 0b430008a..25c727e25 100644 --- a/perllib/FixMyStreet/DB/ResultSet/AlertType.pm +++ b/perllib/FixMyStreet/DB/ResultSet/AlertType.pm @@ -30,18 +30,19 @@ sub email_alerts ($) { $item_table.name as item_name, $item_table.anonymous as item_anonymous, $item_table.confirmed as item_confirmed, $head_table.* - from alert - inner join $item_table on alert.parameter::integer = $item_table.${head_table}_id - inner join $head_table on alert.parameter::integer = $head_table.id + from alert, $item_table, $head_table + where alert.parameter::integer = $head_table.id + and $item_table.${head_table}_id = $head_table.id "; } else { $query .= " $item_table.*, $item_table.id as item_id - from alert, $item_table"; + from alert, $item_table + where 1 = 1"; } $query .= " - where alert_type='$ref' and whendisabled is null and $item_table.confirmed >= whensubscribed - and $item_table.confirmed >= ms_current_timestamp() - '7 days'::interval + and alert_type='$ref' and whendisabled is null and $item_table.confirmed >= whensubscribed + and $item_table.confirmed >= current_timestamp - '7 days'::interval and (select whenqueued from alert_sent where alert_sent.alert_id = alert.id and alert_sent.parameter::integer = $item_table.id) is null and $item_table.user_id <> alert.user_id and " . $alert_type->item_where . " @@ -69,8 +70,6 @@ sub email_alerts ($) { # this is for the new_updates alerts next if $row->{non_public} and $row->{user_id} != $row->{alert_user_id}; - my $hashref_restriction = $cobrand->site_restriction( $row->{cobrand_data} ); - FixMyStreet::App->model('DB::AlertSent')->create( { alert_id => $row->{alert_id}, parameter => $row->{item_id}, @@ -89,10 +88,7 @@ sub email_alerts ($) { $data{state_message} = _("This report is currently marked as open."); } - my $url = $cobrand->base_url( $row->{alert_cobrand_data} ); - if ( $hashref_restriction && $hashref_restriction->{bodies_str} && $row->{bodies_str} ne $hashref_restriction->{bodies_str} ) { - $url = mySociety::Config::get('BASE_URL'); - } + my $url = $cobrand->base_url_for_report($row); # this is currently only for new_updates if ($row->{item_text}) { if ( $cobrand->moniker ne 'zurich' && $row->{alert_user_id} == $row->{user_id} ) { @@ -171,7 +167,6 @@ sub email_alerts ($) { my $longitude = $alert->parameter; my $latitude = $alert->parameter2; - my $hashref_restriction = $cobrand->site_restriction( $alert->cobrand_data ); my $d = mySociety::Gaze::get_radius_containing_population($latitude, $longitude, 200000); # Convert integer to GB locale string (with a ".") $d = mySociety::Locale::in_gb_locale { @@ -184,7 +179,7 @@ sub email_alerts ($) { and problem.user_id = users.id and problem.state in ($states) and problem.non_public = 'f' - and problem.confirmed >= ? and problem.confirmed >= ms_current_timestamp() - '7 days'::interval + and problem.confirmed >= ? and problem.confirmed >= current_timestamp - '7 days'::interval and (select whenqueued from alert_sent where alert_sent.alert_id = ? and alert_sent.parameter::integer = problem.id) is null and users.email <> ? order by confirmed desc"; @@ -195,10 +190,7 @@ sub email_alerts ($) { alert_id => $alert->id, parameter => $row->{id}, } ); - my $url = $cobrand->base_url( $alert->cobrand_data ); - if ( $hashref_restriction && $hashref_restriction->{bodies_str} && $row->{bodies_str} ne $hashref_restriction->{bodies_str} ) { - $url = mySociety::Config::get('BASE_URL'); - } + my $url = $cobrand->base_url_for_report($row); $data{data} .= $url . "/report/" . $row->{id} . " - $row->{title}\n\n"; if ( exists $row->{geocode} && $row->{geocode} ) { my $nearest_st = _get_address_from_gecode( $row->{geocode} ); @@ -241,15 +233,13 @@ sub _send_aggregated_alert_email(%) { my $template = FixMyStreet->get_email_template($cobrand->moniker, $data{lang}, "$data{template}.txt"); - my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL'); my $result = FixMyStreet::App->send_email_cron( { _template_ => $template, _parameters_ => \%data, - From => [ $sender, _($cobrand->contact_name) ], To => $data{alert_email}, }, - $sender, + undef, 0, $cobrand, $data{lang} diff --git a/perllib/FixMyStreet/DB/ResultSet/Comment.pm b/perllib/FixMyStreet/DB/ResultSet/Comment.pm index 70f8027aa..1b6afb819 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Comment.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Comment.pm @@ -4,19 +4,24 @@ use base 'DBIx::Class::ResultSet'; use strict; use warnings; +sub to_body { + my ($rs, $body_restriction) = @_; + return FixMyStreet::DB::ResultSet::Problem::to_body($rs, $body_restriction); +} + + sub timeline { - my ( $rs, $restriction ) = @_; + my ( $rs, $body_restriction ) = @_; my $prefetch = FixMyStreet::App->model('DB')->schema->storage->sql_maker->quote_char ? [ qw/user/ ] : []; - return $rs->search( + return $rs->to_body($body_restriction)->search( { state => 'confirmed', - created => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, - %{ $restriction }, + created => { '>=', \"current_timestamp-'7 days'::interval" }, }, { prefetch => $prefetch, @@ -25,17 +30,18 @@ sub timeline { } sub summary_count { - my ( $rs, $restriction ) = @_; + my ( $rs, $body_restriction ) = @_; - return $rs->search( - $restriction, - { - group_by => ['me.state'], - select => [ 'me.state', { count => 'me.id' } ], - as => [qw/state state_count/], - join => 'problem' - } - ); + my $params = { + group_by => ['me.state'], + select => [ 'me.state', { count => 'me.id' } ], + as => [qw/state state_count/], + }; + if ($body_restriction) { + $rs = $rs->to_body($body_restriction); + $params->{join} = 'problem'; + } + return $rs->search(undef, $params); } 1; diff --git a/perllib/FixMyStreet/DB/ResultSet/Contact.pm b/perllib/FixMyStreet/DB/ResultSet/Contact.pm index 6fa6a03a0..f402b5461 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Contact.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Contact.pm @@ -4,6 +4,8 @@ use base 'DBIx::Class::ResultSet'; use strict; use warnings; +sub me { join('.', shift->current_source_alias, shift || q{}) } + =head2 not_deleted $rs = $rs->not_deleted(); @@ -14,7 +16,7 @@ Filter down to not deleted contacts - which have C<deleted> set to false; sub not_deleted { my $rs = shift; - return $rs->search( { deleted => 0 } ); + return $rs->search( { $rs->me('deleted') => 0 } ); } sub summary_count { diff --git a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm index a0ccb8a6d..a6b00ef7b 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm @@ -19,12 +19,10 @@ sub nearby { if $interval; $params->{id} = { -not_in => $ids } if $ids; - $params = { - %{ $c->cobrand->problems_clause }, - %$params - } if $c->cobrand->problems_clause; $params->{category} = $category if $category; + $rs = FixMyStreet::DB::ResultSet::Problem::to_body($rs, $c->cobrand->body_restriction); + my $attrs = { prefetch => 'problem', bind => [ $mid_lat, $mid_lon, $dist ], @@ -36,16 +34,4 @@ sub nearby { return \@problems; } -# XXX Not currently used, so not migrating at present. -#sub fixed_nearby { -# my ($dist, $mid_lat, $mid_lon) = @_; -# mySociety::Locale::in_gb_locale { select_all( -# "select id, title, latitude, longitude, distance -# from problem_find_nearby(?, ?, $dist) as nearby, problem -# where nearby.problem_id = problem.id and state='fixed' -# site_restriction -# order by lastupdate desc", $mid_lat, $mid_lon); -# } -#} - 1; diff --git a/perllib/FixMyStreet/DB/ResultSet/Problem.pm b/perllib/FixMyStreet/DB/ResultSet/Problem.pm index 7885c28b3..e9f5d0f8e 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Problem.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Problem.pm @@ -20,6 +20,18 @@ sub set_restriction { $site_key = $key; } +sub to_body { + my ($rs, $bodies) = @_; + return $rs unless $bodies; + unless (ref $bodies eq 'ARRAY') { + $bodies = [ map { ref $_ ? $_->id : $_ } $bodies ]; + } + $rs = $rs->search( + \[ "regexp_split_to_array(bodies_str, ',') && ?", [ {} => $bodies ] ] + ); + return $rs; +} + # Front page statistics sub recent_fixed { @@ -168,9 +180,9 @@ sub timeline { return $rs->search( { -or => { - created => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, - confirmed => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, - whensent => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, + created => { '>=', \"current_timestamp-'7 days'::interval" }, + confirmed => { '>=', \"current_timestamp-'7 days'::interval" }, + whensent => { '>=', \"current_timestamp-'7 days'::interval" }, } }, { @@ -198,7 +210,10 @@ sub unique_users { return $rs->search( { state => [ FixMyStreet::DB::Result::Problem->visible_states() ], }, { - select => [ { count => { distinct => 'user_id' } } ], + select => [ { distinct => 'user_id' } ], + as => [ 'user_id' ] + } )->as_subselect_rs->search( undef, { + select => [ { count => 'user_id' } ], as => [ 'count' ] } )->first->get_column('count'); } @@ -235,8 +250,8 @@ sub send_reports { my $site = $site_override || CronFns::site($base_url); my $states = [ 'confirmed', 'fixed' ]; - $states = [ 'unconfirmed', 'confirmed', 'in progress', 'planned', 'closed' ] if $site eq 'zurich'; - my $unsent = FixMyStreet::App->model("DB::Problem")->search( { + $states = [ 'unconfirmed', 'confirmed', 'in progress', 'planned', 'closed', 'investigating' ] if $site eq 'zurich'; + my $unsent = $rs->search( { state => $states, whensent => undef, bodies_str => { '!=', undef }, @@ -320,15 +335,19 @@ sub send_reports { $cobrand->process_additional_metadata_for_email($row, \%h); } - # XXX Needs locks! - # XXX Only copes with at most one missing body - my ($bodies, $missing) = $row->bodies_str =~ /^([\d,]+)(?:\|(\d+))?/; - my @bodies = split(/,/, $bodies); - $bodies = FixMyStreet::App->model("DB::Body")->search( - { id => \@bodies }, + my $bodies = FixMyStreet::App->model("DB::Body")->search( + { id => $row->bodies_str_ids }, { order_by => 'name' }, ); - $missing = FixMyStreet::App->model("DB::Body")->find($missing) if $missing; + + my $missing; + if ($row->bodies_missing) { + my @missing = FixMyStreet::App->model("DB::Body")->search( + { id => [ split /,/, $row->bodies_missing ] }, + { order_by => 'name' } + )->get_column('name')->all; + $missing = join(' / ', @missing) if @missing; + } my @dear; my %reporters = (); @@ -337,7 +356,7 @@ sub send_reports { my $sender = "FixMyStreet::SendReport::" . $sender_info->{method}; if ( ! exists $senders->{ $sender } ) { - warn "No such sender [ $sender ] for body $body->name ( $body->id )"; + warn sprintf "No such sender [ $sender ] for body %s ( %d )", $body->name, $body->id; next; } $reporters{ $sender } ||= $sender->new(); @@ -400,7 +419,7 @@ sub send_reports { $h{missing} = ''; if ($missing) { $h{missing} = '[ ' - . sprintf(_('We realise this problem might be the responsibility of %s; however, we don\'t currently have any contact details for them. If you know of an appropriate contact address, please do get in touch.'), $missing->name) + . sprintf(_('We realise this problem might be the responsibility of %s; however, we don\'t currently have any contact details for them. If you know of an appropriate contact address, please do get in touch.'), $missing) . " ]\n\n"; } @@ -433,8 +452,8 @@ sub send_reports { unless ($result) { $row->update( { - whensent => \'ms_current_timestamp()', - lastupdate => \'ms_current_timestamp()', + whensent => \'current_timestamp', + lastupdate => \'current_timestamp', } ); if ( $cobrand->report_sent_confirmation_email && !$h{anonymous_report}) { _send_report_sent_email( $row, \%h, $nomail, $cobrand ); @@ -468,7 +487,7 @@ sub send_reports { } } my $sending_errors = ''; - my $unsent = FixMyStreet::App->model("DB::Problem")->search( { + my $unsent = $rs->search( { state => [ 'confirmed', 'fixed' ], whensent => undef, bodies_str => { '!=', undef }, @@ -499,7 +518,7 @@ sub _send_report_sent_email { _template_ => $template, _parameters_ => $h, To => $row->user->email, - From => mySociety::Config::get('CONTACT_EMAIL'), + From => [ mySociety::Config::get('CONTACT_EMAIL'), $cobrand->contact_name ], }, mySociety::Config::get('CONTACT_EMAIL'), $nomail, diff --git a/perllib/FixMyStreet/DB/ResultSet/Questionnaire.pm b/perllib/FixMyStreet/DB/ResultSet/Questionnaire.pm index 63a91697d..bf1c68c49 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Questionnaire.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Questionnaire.pm @@ -22,7 +22,7 @@ sub send_questionnaires_period { whensent => [ '-and', { '!=', undef }, - { '<', \"ms_current_timestamp() - '$period'::interval" }, + { '<', \"current_timestamp - '$period'::interval" }, ], send_questionnaire => 1, }; @@ -34,7 +34,7 @@ sub send_questionnaires_period { } else { $q_params->{'-or'} = [ '(select max(whensent) from questionnaire where me.id=problem_id)' => undef, - '(select max(whenanswered) from questionnaire where me.id=problem_id)' => { '<', \"ms_current_timestamp() - '$period'::interval" } + '(select max(whenanswered) from questionnaire where me.id=problem_id)' => { '<', \"current_timestamp - '$period'::interval" } ]; } @@ -70,7 +70,7 @@ sub send_questionnaires_period { my $questionnaire = FixMyStreet::App->model('DB::Questionnaire')->create( { problem_id => $row->id, - whensent => \'ms_current_timestamp()', + whensent => \'current_timestamp', } ); # We won't send another questionnaire unless they ask for it (or it was @@ -84,9 +84,6 @@ sub send_questionnaires_period { } ); $h{url} = $cobrand->base_url($row->cobrand_data) . '/Q/' . $token->token; - my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL'); - my $sender_name = _($cobrand->contact_name); - print "Sending questionnaire " . $questionnaire->id . ", problem " . $row->id . ", token " . $token->token . " to " . $row->user->email . "\n" @@ -97,9 +94,8 @@ sub send_questionnaires_period { _template_ => $template, _parameters_ => \%h, To => [ [ $row->user->email, $row->name ] ], - From => [ $sender, $sender_name ], }, - $sender, + undef, $params->{nomail}, $cobrand ); @@ -117,32 +113,36 @@ sub send_questionnaires_period { sub timeline { my ( $rs, $restriction ) = @_; + my $attrs; + if (%$restriction) { + $attrs = { + -select => [qw/me.*/], + prefetch => [qw/problem/], + } + } return $rs->search( { -or => { - whenanswered => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, - 'me.whensent' => { '>=', \"ms_current_timestamp()-'7 days'::interval" }, + whenanswered => { '>=', \"current_timestamp-'7 days'::interval" }, + 'me.whensent' => { '>=', \"current_timestamp-'7 days'::interval" }, }, %{ $restriction }, }, - { - -select => [qw/me.*/], - prefetch => [qw/problem/], - } + $attrs ); } sub summary_count { my ( $rs, $restriction ) = @_; - return $rs->search( - $restriction, - { - group_by => [ \'whenanswered is not null' ], - select => [ \'(whenanswered is not null)', { count => 'me.id' } ], - as => [qw/answered questionnaire_count/], - join => 'problem' - } - ); + my $params = { + group_by => [ \'whenanswered is not null' ], + select => [ \'(whenanswered is not null)', { count => 'me.id' } ], + as => [qw/answered questionnaire_count/], + }; + if (%$restriction) { + $params->{join} = 'problem'; + } + return $rs->search($restriction, $params); } 1; diff --git a/perllib/FixMyStreet/Email.pm b/perllib/FixMyStreet/Email.pm new file mode 100644 index 000000000..4a2784787 --- /dev/null +++ b/perllib/FixMyStreet/Email.pm @@ -0,0 +1,12 @@ +package FixMyStreet::Email; + +use Utils::Email; +use FixMyStreet; + +sub test_dmarc { + my $email = shift; + return if FixMyStreet->test_mode; + return Utils::Email::test_dmarc($email); +} + +1; diff --git a/perllib/FixMyStreet/Geocode/Google.pm b/perllib/FixMyStreet/Geocode/Google.pm index 35fcec36f..5261bb7e4 100644 --- a/perllib/FixMyStreet/Geocode/Google.pm +++ b/perllib/FixMyStreet/Geocode/Google.pm @@ -28,34 +28,32 @@ sub string { $s = FixMyStreet::Geocode::escape($s); - my $url = 'http://maps.google.com/maps/geo?q=' . $s; - $url .= '&ll=' . $params->{centre} if $params->{centre}; - $url .= '&spn=' . $params->{span} if $params->{span}; + my $url = 'https://maps.googleapis.com/maps/api/geocode/json?address=' . $s; + $url .= '&bounds=' . $params->{bounds}[0] . ',' . $params->{bounds}[1] + . '|' . $params->{bounds}[2] . ',' . $params->{bounds}[3] + if $params->{bounds}; if ($params->{google_country}) { - $url .= '&gl=' . $params->{google_country}; + $url .= '®ion=' . $params->{google_country}; } elsif ($params->{country}) { - $url .= '&gl=' . $params->{country}; + $url .= '®ion=' . $params->{country}; } - $url .= '&hl=' . $params->{lang} if $params->{lang}; + $url .= '&language=' . $params->{lang} if $params->{lang}; - my $args = 'sensor=false&key=' . FixMyStreet->config('GOOGLE_MAPS_API_KEY'); - my $js = FixMyStreet::Geocode::cache('google', $url, $args, qr/"code":6[12]0/); + my $args = 'key=' . FixMyStreet->config('GOOGLE_MAPS_API_KEY'); + my $js = FixMyStreet::Geocode::cache('google', $url, $args, qr/"status"\s*:\s*"(OVER_QUERY_LIMIT|REQUEST_DENIED|INVALID_REQUEST|UNKNOWN_ERROR)"/); if (!$js) { return { error => _('Sorry, we could not parse that location. Please try again.') }; } - if ($js->{Status}->{code} ne '200') { - return { error => _('Sorry, we could not find that location.') }; - } + return unless $js->{status} eq 'OK'; - my $results = $js->{Placemark}; + my $results = $js->{results}; my ( $error, @valid_locations, $latitude, $longitude ); foreach (@$results) { - next unless $_->{AddressDetails}->{Accuracy} >= 4; - my $address = $_->{address}; + my $address = $_->{formatted_address}; next unless $c->cobrand->geocoded_string_check( $address ); ( $longitude, $latitude ) = map { Utils::truncate_coordinate($_) } - @{ $_->{Point}->{coordinates} }; + ($_->{geometry}{location}{lat}, $_->{geometry}{location}{lng}); push (@$error, { address => $address, latitude => $latitude, diff --git a/perllib/FixMyStreet/Geocode/Zurich.pm b/perllib/FixMyStreet/Geocode/Zurich.pm index aad918b0e..50a7c355e 100644 --- a/perllib/FixMyStreet/Geocode/Zurich.pm +++ b/perllib/FixMyStreet/Geocode/Zurich.pm @@ -31,6 +31,7 @@ sub setup_soap { my $action = "$attr/IFixMyZuerich/"; require SOAP::Lite; + # SOAP::Lite->import( +trace => [transport => \&log_message ] ); # Set up the SOAP handler $security = SOAP::Header->name("Security")->attr({ @@ -109,5 +110,15 @@ sub string { return { error => $error }; } +sub log_message { + my ($in) = @_; + eval { + printf "log_message [$in]: %s\n\n", $in->content; # ...for example + }; + if ($@) { + print "log_message [$in]: ???? \n\n"; + } +} + 1; diff --git a/perllib/FixMyStreet/Map.pm b/perllib/FixMyStreet/Map.pm index b050592ba..81b81f656 100644 --- a/perllib/FixMyStreet/Map.pm +++ b/perllib/FixMyStreet/Map.pm @@ -35,6 +35,16 @@ sub allowed_maps { return grep { $avail{$_} } @allowed; } +=head2 reload_allowed_maps + +Allows tests to override MAP_TYPE at run time. + +=cut + +sub reload_allowed_maps { + @ALL_MAP_CLASSES = allowed_maps(); +} + =head2 map_class Set and return the appropriate class given a query parameter string. diff --git a/perllib/FixMyStreet/Map/Zurich.pm b/perllib/FixMyStreet/Map/Zurich.pm index 9b01f2978..3e198f820 100644 --- a/perllib/FixMyStreet/Map/Zurich.pm +++ b/perllib/FixMyStreet/Map/Zurich.pm @@ -11,25 +11,53 @@ use Geo::Coordinates::CH1903; use Math::Trig; use Utils; -use constant ZOOM_LEVELS => 8; +use constant ZOOM_LEVELS => 9; use constant DEFAULT_ZOOM => 5; use constant MIN_ZOOM_LEVEL => 0; use constant ID_OFFSET => 2; +use constant TILE_SIZE => 512; sub map_tiles { - my ( $self, %params ) = @_; - my ( $col, $row, $z ) = ( $params{x_tile}, $params{y_tile}, $params{matrix_id} ); + my ($self, %params) = @_; + my ($left_col, $top_row, $z) = @params{'x_left_tile', 'y_top_tile', 'matrix_id'}; my $tile_url = $self->base_tile_url(); + my $cols = $params{cols}; + my $rows = $params{rows}; + + my @col_offsets = (0.. ($cols-1) ); + my @row_offsets = (0.. ($rows-1) ); + return [ - "$tile_url/$z/" . ($row - 1) . "/" . ($col - 1) . ".jpg", - "$tile_url/$z/" . ($row - 1) . "/$col.jpg", - "$tile_url/$z/$row/" . ($col - 1) . ".jpg", - "$tile_url/$z/$row/$col.jpg", + map { + my $row_offset = $_; + [ + map { + my $col_offset = $_; + my $row = $top_row + $row_offset; + my $col = $left_col + $col_offset; + my $src = sprintf '%s/%d/%d/%d.jpg', + $tile_url, $z, $row, $col; + my $dotted_id = sprintf '%d.%d', $col, $row; + + # return the data structure for the cell + +{ + src => $src, + row_offset => $row_offset, + col_offset => $col_offset, + dotted_id => $dotted_id, + alt => "Map tile $dotted_id", # TODO "NW map tile"? + } + } + @col_offsets + ] + } + @row_offsets ]; } sub base_tile_url { - return '/maps/Hybrid/1.0.0/Hybrid/default/nativeTileMatrixSet'; + # use the new 512px maps as used by Javascript + return 'http://www.gis.stadt-zuerich.ch/maps/rest/services/tiled/LuftbildHybrid/MapServer/WMTS/tile/1.0.0/tiled_LuftbildHybrid/default/default028mm'; } sub copyright { @@ -50,29 +78,77 @@ sub display_map { $params{longitude} = Utils::truncate_coordinate($c->get_param('lon') + 0) if defined $c->get_param('lon'); - my $zoom = defined $c->get_param('zoom') - ? $c->get_param('zoom') + 0 - : $c->stash->{page} eq 'report' - ? DEFAULT_ZOOM+1 - : DEFAULT_ZOOM; - $zoom = ZOOM_LEVELS - 1 if $zoom >= ZOOM_LEVELS; - $zoom = 0 if $zoom < 0; + $params{rows} //= 2; # 2x2 square is default + $params{cols} //= 2; + + $params{zoom} = do { + my $zoom = defined $c->get_param('zoom') + ? $c->get_param('zoom') + 0 + : $c->stash->{page} eq 'report' + ? DEFAULT_ZOOM+1 + : DEFAULT_ZOOM; + $zoom = ZOOM_LEVELS - 1 if $zoom >= ZOOM_LEVELS; + $zoom = 0 if $zoom < 0; + $zoom; + }; + + $c->stash->{map} = $self->get_map_hash( %params ); - ($params{x_tile}, $params{y_tile}, $params{matrix_id}) = latlon_to_tile_with_adjust($params{latitude}, $params{longitude}, $zoom); + if ($params{print_report}) { + $params{zoom}++ unless $params{zoom} >= ZOOM_LEVELS; + $c->stash->{print_report_map} + = $self->get_map_hash( + %params, + img_type => 'img', + cols => 4, rows => 4, + ); + # NB: we can passthrough img_type as literal here, as only designed for print - foreach my $pin (@{$params{pins}}) { - ($pin->{px}, $pin->{py}) = latlon_to_px($pin->{latitude}, $pin->{longitude}, $params{x_tile}, $params{y_tile}, $zoom); + # NB we can do arbitrary size, including non-squares, however we'd have + # to modify .square-map style with padding-bottom percentage calculated in + # an inline style: + # <zarino> in which case, the only change that'd be required is + # removing { padding-bottom: 100% } from .square-map__outer, putting + # the percentage into an inline style on the element itself, and then + # probably renaming .square-map__* to .fixed-aspect-map__* or something + # since it's no longer necessarily square } +} + +sub get_map_hash { + my ($self, %params) = @_; - $c->stash->{map} = { + @params{'x_centre_tile', 'y_centre_tile', 'matrix_id'} + = latlon_to_tile_with_adjust( + @params{'latitude', 'longitude', 'zoom', 'rows', 'cols'}); + + # centre_(row|col) is either in middle, or just to right. + # e.g. if centre is the number in parens: + # 1 (2) 3 => 2 - int( 3/2 ) = 1 + # 1 2 (3) 4 => 3 - int( 4/2 ) = 1 + $params{x_left_tile} = $params{x_centre_tile} - int($params{cols} / 2); + $params{y_top_tile} = $params{y_centre_tile} - int($params{rows} / 2); + + $params{pins} = [ + map { + my $pin = { %$_ }; # shallow clone + ($pin->{px}, $pin->{py}) + = latlon_to_px($pin->{latitude}, $pin->{longitude}, + @params{'x_left_tile', 'y_top_tile', 'zoom'}); + $pin; + } @{ $params{pins} } + ]; + + return { %params, type => 'zurich', map_type => 'OpenLayers.Layer.WMTS', tiles => $self->map_tiles( %params ), copyright => $self->copyright(), - zoom => $zoom, + zoom => $params{zoom},, zoomOffset => MIN_ZOOM_LEVEL, numZoomLevels => ZOOM_LEVELS, + tile_size => TILE_SIZE, }; } @@ -83,29 +159,46 @@ sub latlon_to_tile($$$) { my ($x, $y) = Geo::Coordinates::CH1903::from_latlon($lat, $lon); my $matrix_id = $zoom + ID_OFFSET; - my @scales = ( '250000', '125000', '64000', '32000', '16000', '8000', '4000', '2000', '1000', '500' ); + my @scales = ( + '250000', '125000', + '64000', '32000', + '16000', '8000', + '4000', '2000', + '1000', '500', + '250' + ); my $tileOrigin = { lat => 30814423, lon => -29386322 }; - my $tileSize = 256; - my $res = $scales[$matrix_id] / (39.3701 * 96); # OpenLayers.INCHES_PER_UNIT[units] * OpenLayers.DOTS_PER_INCH + my $res = $scales[$matrix_id] / (39.3701 * 96); + # OpenLayers.INCHES_PER_UNIT[units] * OpenLayers.DOTS_PER_INCH - my $fx = ( $x - $tileOrigin->{lon} ) / ($res * $tileSize); - my $fy = ( $tileOrigin->{lat} - $y ) / ($res * $tileSize); + my $fx = ( $x - $tileOrigin->{lon} ) / ($res * TILE_SIZE); + my $fy = ( $tileOrigin->{lat} - $y ) / ($res * TILE_SIZE); return ( $fx, $fy, $matrix_id ); } # Given a lat/lon, convert it to OSM tile co-ordinates (nearest actual tile, # adjusted so the point will be near the centre of a 2x2 tiled map). -sub latlon_to_tile_with_adjust($$$) { - my ($lat, $lon, $zoom) = @_; - my ($x_tile, $y_tile, $matrix_id) = latlon_to_tile($lat, $lon, $zoom); +# +# Takes parameter for rows/cols. For even sizes (2x2, 4x4 etc.) will +# do adjustment, but simply returns actual for odd sizes. +# +sub latlon_to_tile_with_adjust { + my ($lat, $lon, $zoom, $rows, $cols) = @_; + my ($x_tile, $y_tile, $matrix_id) + = my @ret + = latlon_to_tile($lat, $lon, $zoom); - # Try and have point near centre of map - if ($x_tile - int($x_tile) > 0.5) { - $x_tile += 1; + # Try and have point near centre of map, passing through if odd + unless ($cols % 2) { + if ($x_tile - int($x_tile) > 0.5) { + $x_tile += 1; + } } - if ($y_tile - int($y_tile) > 0.5) { - $y_tile += 1; + unless ($rows % 2) { + if ($y_tile - int($y_tile) > 0.5) { + $y_tile += 1; + } } return ( int($x_tile), int($y_tile), $matrix_id ); @@ -115,13 +208,12 @@ sub tile_to_latlon { my ($fx, $fy, $zoom) = @_; my $matrix_id = $zoom + ID_OFFSET; - my @scales = ( '250000', '125000', '64000', '32000', '16000', '8000', '4000', '2000', '1000', '500' ); + my @scales = ( '250000', '125000', '64000', '32000', '16000', '8000', '4000', '2000', '1000', '500', '250' ); my $tileOrigin = { lat => 30814423, lon => -29386322 }; - my $tileSize = 256; my $res = $scales[$matrix_id] / (39.3701 * 96); # OpenLayers.INCHES_PER_UNIT[units] * OpenLayers.DOTS_PER_INCH - my $x = $fx * $res * $tileSize + $tileOrigin->{lon}; - my $y = $tileOrigin->{lat} - $fy * $res * $tileSize; + my $x = $fx * $res * TILE_SIZE + $tileOrigin->{lon}; + my $y = $tileOrigin->{lat} - $fy * $res * TILE_SIZE; my ($lat, $lon) = Geo::Coordinates::CH1903::to_latlon($x, $y); @@ -141,16 +233,16 @@ sub latlon_to_px($$$$$) { # C is centre tile reference of displayed map sub tile_to_px { my ($p, $c) = @_; - $p = 256 * ($p - $c + 1); + $p = TILE_SIZE * ($p - $c); $p = int($p + .5 * ($p <=> 0)); return $p; } sub click_to_tile { my ($pin_tile, $pin) = @_; - $pin -= 256 while $pin > 256; - $pin += 256 while $pin < 0; - return $pin_tile + $pin / 256; + $pin -= TILE_SIZE while $pin > TILE_SIZE; + $pin += TILE_SIZE while $pin < 0; + return $pin_tile + $pin / TILE_SIZE; } # Given some click co-ords (the tile they were on, and where in the diff --git a/perllib/FixMyStreet/SendReport/Barnet.pm b/perllib/FixMyStreet/SendReport/Barnet.pm deleted file mode 100644 index 07adb4c33..000000000 --- a/perllib/FixMyStreet/SendReport/Barnet.pm +++ /dev/null @@ -1,208 +0,0 @@ -package FixMyStreet::SendReport::Barnet; - -use Moose; - -BEGIN { extends 'FixMyStreet::SendReport'; } - -use Encode; -use Utils; -use mySociety::Config; -use mySociety::Web qw(ent); - -# specific council numbers -use constant COUNCIL_ID_BARNET => 2489; -use constant MAX_LINE_LENGTH => 132; - -sub construct_message { - my %h = @_; - my $message = <<EOF; -Subject: $h{title} - -Details: $h{detail} - -$h{fuzzy}, or to provide an update on the problem, please visit the following link: - -$h{url} - -$h{closest_address} -EOF -} - - -sub send { - my ( $self, $row, $h ) = @_; - - my %h = %$h; - - $h{message} = construct_message(%h); - - my $return = 1; - my $err_msg = ""; - - my $default_kbid = 14; # This is the default, "Street Scene" - my $kbid = sprintf( "%050d", Utils::barnet_categories()->{$h{category}} || $default_kbid); - - my $geo_code = "$h{easting} $h{northing}"; - - require BarnetInterfaces::service::ZLBB_SERVICE_ORDER; - my $interface = BarnetInterfaces::service::ZLBB_SERVICE_ORDER->new(); - - my ($nearest_postcode, $nearest_street) = ('', ''); - for ($h{closest_address}) { - $nearest_postcode = sprintf("%-10s", $1) if /Nearest postcode [^:]+: ((\w{1,4}\s?\w+|\w+))/; - # use partial postcode or comma as delimiter, strip leading number (possible letter 221B) off too - # "99 Foo Street, London N11 1XX" becomes Foo Street - # "99 Foo Street N11 1XX" becomes Foo Street - $nearest_street = $1 if /Nearest road [^:]+: (?:\d+\w? )?(.*?)(\b[A-Z]+\d|,|$)/m; - } - my $postcode = mySociety::PostcodeUtil::is_valid_postcode($h{query}) - ? $h{query} : $nearest_postcode; # use given postcode if available - - # note: endpoint can be of form 'https://username:password@url' - my $body = FixMyStreet::App->model("DB::Body")->search( { 'body_areas.area_id' => COUNCIL_ID_BARNET }, { join => "body_areas" } )->first; - if ($body and $body->endpoint) { - $interface->set_proxy($body->endpoint); - # Barnet web service doesn't like namespaces in the elements so use a prefix - $interface->set_prefix('urn'); - # uncomment these lines to print XML that will be sent rather - # than connecting to the endpoint - #$interface->outputxml(1); - #$interface->no_dispatch(1); - } else { - die "Barnet webservice FAIL: looks like you're missing some config data: no endpoint (URL) found for area ID " . COUNCIL_ID_BARNET; - } - - eval { - my $result = $interface->Z_CRM_SERVICE_ORDER_CREATE( { - ET_RETURN => { # ignored by server - item => { - TYPE => "", ID => "", NUMBER => "", MESSAGE => "", LOG_NO => "", LOG_MSG_NO => "", - MESSAGE_V1 => "", MESSAGE_V2 => "", MESSAGE_V3 => "", MESSAGE_V4 => "", PARAMETER => "", - ROW => "", FIELD => "", SYSTEM => "", - }, - }, - IT_PROBLEM_DESC => { # MyTypes::TABLE_OF_CRMT_SERVICE_REQUEST_TEXT - item => [ # MyTypes::CRMT_SERVICE_REQUEST_TEXT - map { { TEXT_LINE => $_ } } split_text_with_entities(ent(encode_utf8($h{message})), 132) # char132 - ], - }, - IV_CUST_EMAIL => truncate_string_with_entities(ent(encode_utf8($h{email})), 241), # char241 - IV_CUST_NAME => truncate_string_with_entities(ent(encode_utf8($h{name})), 50), # char50 - IV_KBID => $kbid, # char50 - IV_PROBLEM_ID => $h{id}, # char35 - IV_PROBLEM_LOC => { # MyTypes::BAPI_TTET_ADDRESS_COM - COUNTRY2 => 'GB', # char2 - REGION => "", # char3 - COUNTY => "", # char30 - CITY => "", # char30 - POSTALCODE => $postcode, # char10 - STREET => truncate_string_with_entities(ent(encode_utf8($nearest_street)), 30), # char30 - STREETNUMBER => "", # char5 - GEOCODE => $geo_code, # char32 - }, - IV_PROBLEM_SUB => truncate_string_with_entities(ent(encode_utf8($h{title})), 40), # char40 - }, - ); - if ($result) { - # currently not using this: get_EV_ORDER_GUID (maybe that's the customer number in the CRM) - if (my $barnet_id = $result->get_EV_ORDER_NO()) { - $row->external_id( $barnet_id ); - $row->external_body( 'Barnet Borough Council' ); # better to use $row->body()? - $row->send_method_used('barnet'); - $return = 0; - } else { - my @returned_items = split /<item[^>]*>/, $result->get_ET_RETURN; - my @messages = (); - foreach my $item (@returned_items) { - if ($item=~/<MESSAGE [^>]*>\s*(\S.*?)<\/MESSAGE>/) { # if there's a non-null MESSAGE in there, grab it - push @messages, $1; # best stab at extracting useful error message back from convoluted response - } - } - push @messages, "service returned no external id" unless @messages; - $err_msg = "Failed (problem id $h{id}): " . join(" \n ", @messages); - } - } else { - my %fault = ( - 'code' => $result->get_faultcode(), - 'actor' => $result->get_faultactor(), - 'string' => $result->get_faultstring(), - 'detail' => $result->get_detail(), # possibly only contains debug info - ); - foreach (keys %fault) { - $fault{$_}="" unless defined $fault{$_}; - $fault{$_}=~s/^\s*|\s*$//g; - } - $fault{actor}&&=" (actor: $fault{actor})"; - $fault{'detail'} &&= "\n" . $fault{'detail'}; - $err_msg = "Failed (problem id $h{id}): Fault $fault{code}$fault{actor}\n$fault{string}$fault{detail}"; - } - - }; - if ($err_msg) { - # for timeouts, we can tidy the message a wee bit (i.e. strip the 'error deserializing...' message) - $err_msg=~s/(?:Error deserializing message:.*)(Can't connect to [a-zA-Z0-9.:]+\s*\(Connection timed out\)).*/$1/s; - print "$err_msg\n"; - } - if ($@) { - my $e = shift; - print "Caught an error: $@\n"; - } - if ( $return ) { - $self->error( "Error sending to Barnet: $err_msg" ); - } - $self->success( !$return ); - return $return; -} - -# for barnet webservice: max-length fields require truncate and split - -# truncate_string_with_entities -# args: text to truncate -# max number of chars -# returns: string truncated -# Note: must not partially truncate an entity (e.g., &) -sub truncate_string_with_entities { - my ($str, $max_len) = @_; - my $retVal = ""; - foreach my $chunk (split /(\&(?:\#\d+|\w+);)/, $str) { - if ($chunk=~/^\&(\#\d+|\w+);$/){ - my $next = $retVal.$chunk; - last if length $next > $max_len; - $retVal=$next - } else { - $retVal.=$chunk; - if (length $retVal > $max_len) { - $retVal = substr($retVal, 0, $max_len); - last - } - } - } - return $retVal -} - -# split_text_with_entities into lines -# args: text to be broken into lines -# max length (option: uses constant MAX_LINE_LENGTH) -# returns: array of lines -# Must not to split an entity (e.g., &) -# Not worrying about hyphenating here, since a word is only ever split if -# it's longer than the whole line, which is uncommon in genuine problem reports -sub split_text_with_entities { - my ($text, $max_line_length) = @_; - $max_line_length ||= MAX_LINE_LENGTH; - my @lines; - foreach my $line (split "\n", $text) { - while (length $line > $max_line_length) { - if (! ($line =~ s/^(.{1,$max_line_length})\s// # break on a space - or $line =~ s/^(.{1,$max_line_length})(\&(\#\d+|\w+);)/$2/ # break before an entity - or $line =~ s/(.{$max_line_length})//)) { # break the word ruthlessly - $line =~ s/(.*)//; # otherwise gobble whole line (which is now shorter than max length) - } - push @lines, $1; - } - push @lines, $line; - } - return @lines; -} - -1; diff --git a/perllib/FixMyStreet/SendReport/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm index fa4d437fb..bac408510 100644 --- a/perllib/FixMyStreet/SendReport/Email.pm +++ b/perllib/FixMyStreet/SendReport/Email.pm @@ -1,6 +1,7 @@ package FixMyStreet::SendReport::Email; use Moose; +use FixMyStreet::Email; BEGIN { extends 'FixMyStreet::SendReport'; } @@ -92,8 +93,19 @@ sub send { To => $self->to, From => $self->send_from( $row ), }; + + my $app = FixMyStreet::App->new( cobrand => $cobrand ); + + $cobrand->munge_sendreport_params($app, $row, $h, $params) if $cobrand->can('munge_sendreport_params'); + $params->{Bcc} = $self->bcc if @{$self->bcc}; - my $result = FixMyStreet::App->send_email_cron( + + if (FixMyStreet::Email::test_dmarc($params->{From}[0])) { + $params->{'Reply-To'} = [ $params->{From} ]; + $params->{From} = [ mySociety::Config::get('CONTACT_EMAIL'), $params->{From}[1] ]; + } + + my $result = $app->send_email_cron( $params, mySociety::Config::get('CONTACT_EMAIL'), $nomail, diff --git a/perllib/FixMyStreet/SendReport/Zurich.pm b/perllib/FixMyStreet/SendReport/Zurich.pm index 40417b41e..2838440cb 100644 --- a/perllib/FixMyStreet/SendReport/Zurich.pm +++ b/perllib/FixMyStreet/SendReport/Zurich.pm @@ -9,10 +9,21 @@ sub build_recipient_list { # Only one body ever, most of the time with an email endpoint my $body = @{ $self->bodies }[0]; + + # we set external_message (but default to '' in case of race condition e.g. + # Wunsch set, but external_message hasn't yet been filled in. TODO should + # we instead be holding off sending?) if ( $row->external_body ) { $body = FixMyStreet::App->model("DB::Body")->find( { id => $row->external_body } ); $h->{bodies_name} = $body->name; + $h->{external_message} = $row->get_extra_metadata('external_message') || ''; } + $h->{external_message} //= ''; + + my ($west, $nord) = $row->local_coords; + $h->{west} = $west; + $h->{nord} = $nord; + my $body_email = $body->endpoint; my $parent = $body->parent; @@ -39,6 +50,8 @@ sub get_template { $template = 'submit-in-progress.txt'; } elsif ( $row->state eq 'planned' ) { $template = 'submit-feedback-pending.txt'; + } elsif ( $row->state eq 'investigating' ) { + $template = 'submit-external-wish.txt'; } elsif ( $row->state eq 'closed' ) { $template = 'submit-external.txt'; if ( $row->extra->{third_personal} ) { diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index bd2ca4096..cc5f9dd71 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -165,6 +165,7 @@ sub delete_user { $a->delete; } $_->delete for $user->comments; + $_->delete for $user->admin_logs; $user->delete; return 1; @@ -221,6 +222,24 @@ sub get_email { return $emails[0]; } +=head2 get_first_email + + $email = $mech->get_first_email(@emails); + +Returns first email in queue as a string and fails a test if the mail doesn't have a date and epoch-containing Message-ID header. + +=cut + +sub get_first_email { + my $mech = shift; + my $email = shift or do { fail 'No email retrieved'; return }; + my $email_as_string = $email->as_string; + ok $email_as_string =~ s{\s+Date:\s+\S.*?$}{}xmsg, "Found and stripped out date"; + ok $email_as_string =~ s{\s+Message-ID:\s+\S.*?$}{}xmsg, "Found and stripped out message ID (contains epoch)"; + return $email_as_string; +} + + =head2 page_errors my $arrayref = $mech->page_errors; @@ -383,7 +402,7 @@ sub extract_update_metas { my $result = scraper { process 'div#updates div.problem-update p em', 'meta[]', 'TEXT'; - process '.update-text .meta-2', 'meta[]', 'TEXT'; + process '.item-list__update-text .meta-2', 'meta[]', 'TEXT'; } ->scrape( $mech->response ); @@ -404,7 +423,7 @@ sub extract_problem_list { my $mech = shift; my $result = scraper { - process 'ul.issue-list-a li a h4', 'problems[]', 'TEXT'; + process 'ul.item-list--reports li a h4', 'problems[]', 'TEXT'; }->scrape( $mech->response ); return $result->{ problems } || []; @@ -631,7 +650,7 @@ sub create_problems_for_body { latitude => '51.5016605453401', longitude => '-0.142497580865087', user_id => $user->id, - photo => 1, + photo => $mech->get_photo_data, }; my %report_params = ( %$default_params, %$params ); @@ -646,4 +665,13 @@ sub create_problems_for_body { return @problems; } +sub get_photo_data { + my $mech = shift; + return $mech->{sample_photo} ||= do { + my $sample_file = FixMyStreet->path_to( 't/app/controller/sample.jpg' ); + $mech->builder->ok( -f "$sample_file", "sample file $sample_file exists" ); + $sample_file->slurp(iomode => '<:raw'); + }; +} + 1; |