diff options
Diffstat (limited to 'perllib')
22 files changed, 1519 insertions, 254 deletions
diff --git a/perllib/FixMyStreet.pm b/perllib/FixMyStreet.pm index 76760b967..76befb96a 100644 --- a/perllib/FixMyStreet.pm +++ b/perllib/FixMyStreet.pm @@ -124,11 +124,14 @@ sub override_config($&) { } ); + FixMyStreet::Map::reload_allowed_maps() if $config->{MAP_TYPE}; + $code->(); $override_guard1->restore(); $override_guard2->restore(); - mySociety::MaPit::configure() if $config->{MAPIT_URL};; + mySociety::MaPit::configure() if $config->{MAPIT_URL}; + FixMyStreet::Map::reload_allowed_maps() if $config->{MAP_TYPE}; } =head2 dbic_connect_info diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index 5e0bbaf93..c9286b177 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -207,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) { @@ -318,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 Reply-To); - - 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} @@ -339,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); @@ -359,17 +366,7 @@ sub send_email_cron { $params->{From} = [ $sender, _($sender_name) ]; } - 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}; - } - 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') @@ -403,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 @@ -418,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( ... ); @@ -533,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 f9ea383f8..39d6ff72f 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; @@ -363,6 +364,14 @@ sub update_contacts : Private { $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; @@ -669,12 +678,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' ) { @@ -810,6 +828,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 ) = @_; @@ -1251,13 +1348,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(); } @@ -1370,36 +1478,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; - - # 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; + my $problem = $c->stash->{problem}; + my $fileid = $problem->get_photoset($c)->rotate_image( + $index, + $direction eq _('Rotate Left') ? -90 : 90 + ) or return; - 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; } @@ -1449,18 +1548,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/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/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 279994e47..d7cae05d4 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -99,13 +99,21 @@ 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.') ] # diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index d5b84815b..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'); @@ -290,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; @@ -889,8 +890,23 @@ sub process_report : Private { $report->bodies_str(-1); } else { # construct the bodies string: - # 'x,x' - x are body IDs that have this category - my $body_string = join( ',', map { $_->body_id } @contacts ); + 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} }) { @@ -1162,6 +1178,9 @@ sub redirect_or_confirm_creation : Private { } } ); $c->stash->{token_url} = $c->uri_for_email( '/P', $token->token ); + 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/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm index 9dc0ad6bc..3bd7e592b 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'; 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 9cc571efc..37a81e444 100644 --- a/perllib/FixMyStreet/App/View/Web.pm +++ b/perllib/FixMyStreet/App/View/Web.pm @@ -133,6 +133,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/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index c347d5750..9541f2601 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -913,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/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index 9ed65c5f5..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( \'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 fcf909692..d60915cfc 100644 --- a/perllib/FixMyStreet/DB/Result/AdminLog.pm +++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm @@ -37,6 +37,8 @@ __PACKAGE__->add_columns( { 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( 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/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm index 9706087aa..3b7f8bcfd 100644 --- a/perllib/FixMyStreet/DB/Result/Problem.pm +++ b/perllib/FixMyStreet/DB/Result/Problem.pm @@ -323,10 +323,6 @@ sub visible_states_remove { } } -sub visible_states_add_unconfirmed { - $_[0]->visible_states_add('unconfirmed') -} - =head2 @states = FixMyStreet::DB::Problem::council_states(); @@ -483,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'); } @@ -637,7 +639,28 @@ sub body { return $body; } -# returns true if the external id is the council's ref, i.e., useful to publish it. +=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). # Future: this is installation-dependent so maybe should be using the contact # data to determine if the external id is public on a council-by-council basis. # Note: this only makes sense when called on a problem that has been sent! @@ -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/ResultSet/Problem.pm b/perllib/FixMyStreet/DB/ResultSet/Problem.pm index f01ddfff1..40076d374 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Problem.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Problem.pm @@ -247,7 +247,7 @@ 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'; + $states = [ 'unconfirmed', 'confirmed', 'in progress', 'planned', 'closed', 'investigating' ] if $site eq 'zurich'; my $unsent = $rs->search( { state => $states, whensent => undef, 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/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm index 9fec0ac9c..bac408510 100644 --- a/perllib/FixMyStreet/SendReport/Email.pm +++ b/perllib/FixMyStreet/SendReport/Email.pm @@ -93,6 +93,11 @@ 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}; if (FixMyStreet::Email::test_dmarc($params->{From}[0])) { @@ -100,7 +105,7 @@ sub send { $params->{From} = [ mySociety::Config::get('CONTACT_EMAIL'), $params->{From}[1] ]; } - my $result = FixMyStreet::App->send_email_cron( + 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..1035a47ba 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; @@ -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; |