diff options
Diffstat (limited to 'perllib')
63 files changed, 2124 insertions, 905 deletions
diff --git a/perllib/Email/Sender/Transport/SMTP.pm b/perllib/Email/Sender/Transport/SMTP.pm new file mode 100644 index 000000000..c4eb6890c --- /dev/null +++ b/perllib/Email/Sender/Transport/SMTP.pm @@ -0,0 +1,380 @@ +package Email::Sender::Transport::SMTP; +# ABSTRACT: send email over SMTP +$Email::Sender::Transport::SMTP::VERSION = '1.300030'; +use Moo; + +use Email::Sender::Failure::Multi; +use Email::Sender::Success::Partial; +use Email::Sender::Role::HasMessage (); +use Email::Sender::Util; +use MooX::Types::MooseLike::Base qw(Bool Int Str HashRef); +use Net::SMTP 3.07; # SSL support, fixed datasend + +use utf8 (); # See below. -- rjbs, 2015-05-14 +use version (); + +#pod =head1 DESCRIPTION +#pod +#pod This transport is used to send email over SMTP, either with or without secure +#pod sockets (SSL/TLS). It is one of the most complex transports available, capable +#pod of partial success. +#pod +#pod For a potentially more efficient version of this transport, see +#pod L<Email::Sender::Transport::SMTP::Persistent>. +#pod +#pod =head1 ATTRIBUTES +#pod +#pod The following attributes may be passed to the constructor: +#pod +#pod =over 4 +#pod +#pod =item C<host>: the name of the host to connect to; defaults to C<localhost> +#pod +#pod =item C<ssl>: if 'starttls', use STARTTLS; if 'ssl' (or 1), connect securely; +#pod otherwise, no security +#pod +#pod =item C<ssl_options>: passed to Net::SMTP constructor for 'ssl' connections or +#pod to starttls for 'starttls' connections; should contain extra options for +#pod IO::Socket::SSL +#pod +#pod =item C<port>: port to connect to; defaults to 25 for non-SSL, 465 for 'ssl', +#pod 587 for 'starttls' +#pod +#pod =item C<timeout>: maximum time in secs to wait for server; default is 120 +#pod +#pod =cut + +sub BUILD { + my ($self) = @_; + Carp::croak("do not pass port number to SMTP transport in host, use port parameter") + if $self->host =~ /:/; +} + +has host => (is => 'ro', isa => Str, default => sub { 'localhost' }); +has ssl => (is => 'ro', isa => Str, default => sub { 0 }); + +has _security => ( + is => 'ro', + lazy => 1, + init_arg => undef, + default => sub { + my $ssl = $_[0]->ssl; + return '' unless $ssl; + $ssl = lc $ssl; + return 'starttls' if 'starttls' eq $ssl; + return 'ssl' if $ssl eq 1 or $ssl eq 'ssl'; + + Carp::cluck(qq{true "ssl" argument to Email::Sender::Transport::SMTP should be 'ssl' or 'startls' or '1' but got '$ssl'}); + + return 1; + }, +); + +has ssl_options => (is => 'ro', isa => HashRef, default => sub { {} }); + +has port => ( + is => 'ro', + isa => Int, + lazy => 1, + default => sub { + return $_[0]->_security eq 'starttls' ? 587 + : $_[0]->_security eq 'ssl' ? 465 + : 25 + }, +); + +has timeout => (is => 'ro', isa => Int, default => sub { 120 }); + +#pod =item C<sasl_username>: the username to use for auth; optional +#pod +#pod =item C<sasl_password>: the password to use for auth; required if C<username> is provided +#pod +#pod =item C<allow_partial_success>: if true, will send data even if some recipients were rejected; defaults to false +#pod +#pod =cut + +has sasl_username => (is => 'ro', isa => Str); +has sasl_password => (is => 'ro', isa => Str); + +has allow_partial_success => (is => 'ro', isa => Bool, default => sub { 0 }); + +#pod =item C<helo>: what to say when saying HELO; no default +#pod +#pod =item C<localaddr>: local address from which to connect +#pod +#pod =item C<localport>: local port from which to connect +#pod +#pod =cut + +has helo => (is => 'ro', isa => Str); +has localaddr => (is => 'ro'); +has localport => (is => 'ro', isa => Int); + +#pod =item C<debug>: if true, put the L<Net::SMTP> object in debug mode +#pod +#pod =back +#pod +#pod =cut + +has debug => (is => 'ro', isa => Bool, default => sub { 0 }); + +# I am basically -sure- that this is wrong, but sending hundreds of millions of +# messages has shown that it is right enough. I will try to make it textbook +# later. -- rjbs, 2008-12-05 +sub _quoteaddr { + my $addr = shift; + my @localparts = split /\@/, $addr; + my $domain = pop @localparts; + my $localpart = join q{@}, @localparts; + + return $addr # The first regex here is RFC 821 "specials" excepting dot. + unless $localpart =~ /[\x00-\x1F\x7F<>\(\)\[\]\\,;:@"]/ + or $localpart =~ /^\./ + or $localpart =~ /\.$/; + return join q{@}, qq("$localpart"), $domain; +} + +sub _smtp_client { + my ($self) = @_; + + my $class = "Net::SMTP"; + + my $smtp = $class->new( $self->_net_smtp_args ); + + unless ($smtp) { + $self->_throw( + sprintf "unable to establish SMTP connection to %s port %s", + $self->host, + $self->port, + ); + } + + if ($self->_security eq 'starttls') { + $self->_throw("can't STARTTLS: " . $smtp->message) + unless $smtp->starttls(%{ $self->ssl_options }); + } + + if ($self->sasl_username) { + $self->_throw("sasl_username but no sasl_password") + unless defined $self->sasl_password; + + unless ($smtp->auth($self->sasl_username, $self->sasl_password)) { + if ($smtp->message =~ /MIME::Base64|Authen::SASL/) { + Carp::confess("SMTP auth requires MIME::Base64 and Authen::SASL"); + } + + $self->_throw('failed AUTH', $smtp); + } + } + + return $smtp; +} + +sub _net_smtp_args { + my ($self) = @_; + + return ( + $self->host, + Port => $self->port, + Timeout => $self->timeout, + Debug => $self->debug, + + (($self->_security eq 'ssl') + ? (SSL => 1, %{ $self->ssl_options }) + : ()), + + defined $self->helo ? (Hello => $self->helo) : (), + defined $self->localaddr ? (LocalAddr => $self->localaddr) : (), + defined $self->localport ? (LocalPort => $self->localport) : (), + ); +} + +sub _throw { + my ($self, @rest) = @_; + Email::Sender::Util->_failure(@rest)->throw; +} + +sub send_email { + my ($self, $email, $env) = @_; + + Email::Sender::Failure->throw("no valid addresses in recipient list") + unless my @to = grep { defined and length } @{ $env->{to} }; + + my $smtp = $self->_smtp_client; + + my $FAULT = sub { $self->_throw($_[0], $smtp); }; + + $smtp->mail(_quoteaddr($env->{from})) + or $FAULT->("$env->{from} failed after MAIL FROM"); + + my @failures; + my @ok_rcpts; + + for my $addr (@to) { + if ($smtp->to(_quoteaddr($addr))) { + push @ok_rcpts, $addr; + } else { + # my ($self, $error, $smtp, $error_class, @rest) = @_; + push @failures, Email::Sender::Util->_failure( + undef, + $smtp, + recipients => [ $addr ], + ); + } + } + + # This logic used to include: or (@ok_rcpts == 1 and $ok_rcpts[0] eq '0') + # because if called without SkipBad, $smtp->to can return 1 or 0. This + # should not happen because we now always pass SkipBad and do the counting + # ourselves. Still, I've put this comment here (a) in memory of the + # suffering it caused to have to find that problem and (b) in case the + # original problem is more insidious than I thought! -- rjbs, 2008-12-05 + + if ( + @failures + and ((@ok_rcpts == 0) or (! $self->allow_partial_success)) + ) { + $failures[0]->throw if @failures == 1; + + my $message = sprintf '%s recipients were rejected during RCPT', + @ok_rcpts ? 'some' : 'all'; + + Email::Sender::Failure::Multi->throw( + message => $message, + failures => \@failures, + ); + } + + # restore Pobox's support for streaming, code-based messages, and arrays here + # -- rjbs, 2008-12-04 + + $smtp->data or $FAULT->("error at DATA start"); + + my $msg_string = $email->as_string; + my $hunk_size = $self->_hunk_size; + + while (length $msg_string) { + my $next_hunk = substr $msg_string, 0, $hunk_size, ''; + $smtp->datasend($next_hunk) or $FAULT->("error at during DATA"); + } + + $smtp->dataend or $FAULT->("error at after DATA"); + + my $message = $smtp->message; + + $self->_message_complete($smtp); + + # We must report partial success (failures) if applicable. + return $self->success({ message => $message }) unless @failures; + return $self->partial_success({ + message => $message, + failure => Email::Sender::Failure::Multi->new({ + message => 'some recipients were rejected during RCPT', + failures => \@failures + }), + }); +} + +sub _hunk_size { 2**20 } # send messages to DATA in hunks of 1 mebibyte + +sub success { + my $self = shift; + my $success = Moo::Role->create_class_with_roles('Email::Sender::Success', 'Email::Sender::Role::HasMessage')->new(@_); +} + +sub partial_success { + my $self = shift; + my $partial_success = Moo::Role->create_class_with_roles('Email::Sender::Success::Partial', 'Email::Sender::Role::HasMessage')->new(@_); +} + +sub _message_complete { $_[1]->quit; } + +#pod =head1 PARTIAL SUCCESS +#pod +#pod If C<allow_partial_success> was set when creating the transport, the transport +#pod may return L<Email::Sender::Success::Partial> objects. Consult that module's +#pod documentation. +#pod +#pod =cut + +with 'Email::Sender::Transport'; +no Moo; +1; + +__END__ + +=pod + +=encoding UTF-8 + +=head1 NAME + +Email::Sender::Transport::SMTP - send email over SMTP + +=head1 VERSION + +version 1.300030 + +=head1 DESCRIPTION + +This transport is used to send email over SMTP, either with or without secure +sockets (SSL/TLS). It is one of the most complex transports available, capable +of partial success. + +For a potentially more efficient version of this transport, see +L<Email::Sender::Transport::SMTP::Persistent>. + +=head1 ATTRIBUTES + +The following attributes may be passed to the constructor: + +=over 4 + +=item C<host>: the name of the host to connect to; defaults to C<localhost> + +=item C<ssl>: if 'starttls', use STARTTLS; if 'ssl' (or 1), connect securely; +otherwise, no security + +=item C<ssl_options>: passed to Net::SMTP constructor for 'ssl' connections or +to starttls for 'starttls' connections; should contain extra options for +IO::Socket::SSL + +=item C<port>: port to connect to; defaults to 25 for non-SSL, 465 for 'ssl', +587 for 'starttls' + +=item C<timeout>: maximum time in secs to wait for server; default is 120 + +=item C<sasl_username>: the username to use for auth; optional + +=item C<sasl_password>: the password to use for auth; required if C<username> is provided + +=item C<allow_partial_success>: if true, will send data even if some recipients were rejected; defaults to false + +=item C<helo>: what to say when saying HELO; no default + +=item C<localaddr>: local address from which to connect + +=item C<localport>: local port from which to connect + +=item C<debug>: if true, put the L<Net::SMTP> object in debug mode + +=back + +=head1 PARTIAL SUCCESS + +If C<allow_partial_success> was set when creating the transport, the transport +may return L<Email::Sender::Success::Partial> objects. Consult that module's +documentation. + +=head1 AUTHOR + +Ricardo Signes <rjbs@cpan.org> + +=head1 COPYRIGHT AND LICENSE + +This software is copyright (c) 2016 by Ricardo Signes. + +This is free software; you can redistribute it and/or modify it under +the same terms as the Perl 5 programming language system itself. + +=cut diff --git a/perllib/FixMyStreet.pm b/perllib/FixMyStreet.pm index 14f3f3607..1f4579293 100644 --- a/perllib/FixMyStreet.pm +++ b/perllib/FixMyStreet.pm @@ -212,4 +212,18 @@ sub set_time_zone { $dt->set_time_zone($tz_f) if $tz_f; } +# Development functions + +sub staging_flag { + my ($cls, $flag, $value) = @_; + $value = 1 unless defined $value; + return unless $cls->config('STAGING_SITE'); + my $flags = $cls->config('STAGING_FLAGS'); + unless ($flags && ref $flags eq 'HASH') { + # Assume all flags 0 if missing + return !$value; + } + return $flags->{$flag} == $value; +} + 1; diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index 7809b5f12..35e8c2537 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -4,13 +4,16 @@ use namespace::autoclean; use Catalyst::Runtime 5.80; use FixMyStreet; +use FixMyStreet::App::Response; use FixMyStreet::Cobrand; use Memcached; use FixMyStreet::Map; use FixMyStreet::Email; +use FixMyStreet::Email::Sender; use Utils; -use Path::Class; +use Path::Tiny 'path'; +use Try::Tiny; use URI; use URI::QueryParam; @@ -82,6 +85,8 @@ __PACKAGE__->config( }, ); +__PACKAGE__->response_class('FixMyStreet::App::Response'); + # Start the application __PACKAGE__->setup(); @@ -100,6 +105,13 @@ after 'prepare_headers' => sub { __PACKAGE__->log->disable('debug') # unless __PACKAGE__->debug; +# Check upload_dir +my $cache_dir = path(FixMyStreet->config('UPLOAD_DIR'))->absolute(FixMyStreet->path_to()); +$cache_dir->mkpath; +unless ( -d $cache_dir && -w $cache_dir ) { + warn "\x1b[31mCan't find/write to photo cache directory '$cache_dir'\x1b[0m\n"; +} + =head1 NAME FixMyStreet::App - Catalyst based application @@ -160,7 +172,7 @@ sub setup_request { my $cobrand = $c->cobrand; - $cobrand->add_response_headers if $cobrand->can('add_response_headers'); + $cobrand->call_hook('add_response_headers'); # append the cobrand templates to the include path $c->stash->{additional_template_paths} = $cobrand->path_to_web_templates; @@ -336,8 +348,13 @@ sub send_email { $data->{_html_images_} = \@inline_images if @inline_images; my $email = mySociety::Locale::in_gb_locale { FixMyStreet::Email::construct_email($data) }; - my $return = $c->model('EmailSend')->send($email); - $c->log->error("$return") if !$return; + + try { + FixMyStreet::Email::Sender->send($email, { from => $sender }); + } catch { + my $error = $_ || 'unknown error'; + $c->log->error("$error"); + }; return $email; } diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index bbdf449aa..1f3307710 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -7,7 +7,7 @@ BEGIN { extends 'Catalyst::Controller'; } use Path::Class; use POSIX qw(strftime strcoll); use Digest::SHA qw(sha1_hex); -use mySociety::EmailUtil qw(is_valid_email); +use mySociety::EmailUtil qw(is_valid_email is_valid_email_list); use mySociety::ArrayUtils; use DateTime::Format::Strptime; use List::Util 'first'; @@ -78,7 +78,7 @@ sub index : Path : Args(0) { $c->forward('stats_by_state'); my @unsent = $c->cobrand->problems->search( { - state => [ 'confirmed' ], + state => [ FixMyStreet::DB::Result::Problem::open_states() ], whensent => undef, bodies_str => { '!=', undef }, } )->all; @@ -357,10 +357,11 @@ sub update_contacts : Private { } ); - my $email = $self->trim( $c->get_param('email') ); + my $email = $c->get_param('email'); + $email =~ s/\s+//g; my $send_method = $c->get_param('send_method') || $contact->send_method || $contact->body->send_method || ""; unless ( $send_method eq 'Open311' ) { - $errors{email} = _('Please enter a valid email') unless is_valid_email($email) || $email eq 'REFUSED'; + $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED'; } $contact->email( $email ); @@ -732,7 +733,7 @@ sub report_edit : Path('report_edit') : Args(1) { } } - $c->stash->{categories} = $c->forward('categories_for_point'); + $c->forward('categories_for_point'); if ( $c->cobrand->moniker eq 'zurich' ) { my $done = $c->cobrand->admin_report_edit(); @@ -789,11 +790,9 @@ sub report_edit : Path('report_edit') : Args(1) { $c->forward( '/admin/report_edit_category', [ $problem ] ); - if ( $c->get_param('email') ne $problem->user->email ) { - my $user = $c->model('DB::User')->find_or_create( - { email => $c->get_param('email') } - ); - + my $email = lc $c->get_param('email'); + if ( $email ne $problem->user->email ) { + my $user = $c->model('DB::User')->find_or_create({ email => $email }); $user->insert unless $user->in_storage; $problem->user( $user ); } @@ -910,7 +909,8 @@ sub categories_for_point : Private { # Remove the "Pick a category" option shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}}; - return $c->stash->{category_options}; + $c->stash->{categories} = $c->stash->{category_options}; + $c->stash->{categories_hash} = { map { $_ => 1 } @{$c->stash->{category_options}} }; } sub templates : Path('templates') : Args(0) { @@ -978,6 +978,7 @@ sub template_edit : Path('templates') : Args(2) { } else { $template->title( $c->get_param('title') ); $template->text( $c->get_param('text') ); + $template->state( $c->get_param('state') ); $template->auto_response( $c->get_param('auto_response') ? 1 : 0 ); $template->update_or_insert; @@ -1005,10 +1006,9 @@ sub load_template_body : Private { my ($self, $c, $body_id) = @_; my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich'; - my $has_permission = $c->user->has_body_permission_to('template_edit') && - $c->user->from_body->id eq $body_id; + my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id); - unless ( $c->user->is_superuser || $zurich_user || $has_permission ) { + unless ( $zurich_user || $has_permission ) { $c->detach( '/page_error_404_not_found', [] ); } @@ -1117,8 +1117,9 @@ sub update_edit : Path('update_edit') : Args(1) { # $update->name can be null which makes ne unhappy my $name = $update->name || ''; + my $email = lc $c->get_param('email'); if ( $c->get_param('name') ne $name - || $c->get_param('email') ne $update->user->email + || $email ne $update->user->email || $c->get_param('anonymous') ne $update->anonymous || $c->get_param('text') ne $update->text ) { $edited = 1; @@ -1138,11 +1139,8 @@ sub update_edit : Path('update_edit') : Args(1) { $update->anonymous( $c->get_param('anonymous') ); $update->state( $new_state ); - if ( $c->get_param('email') ne $update->user->email ) { - my $user = - $c->model('DB::User') - ->find_or_create( { email => $c->get_param('email') } ); - + if ( $email ne $update->user->email ) { + my $user = $c->model('DB::User')->find_or_create({ email => $email }); $user->insert unless $user->in_storage; $update->user($user); } @@ -1207,7 +1205,7 @@ sub user_add : Path('user_edit') : Args(0) { my $user = $c->model('DB::User')->find_or_create( { name => $c->get_param('name'), - email => $c->get_param('email'), + email => lc $c->get_param('email'), phone => $c->get_param('phone') || undef, from_body => $c->get_param('body') || undef, flagged => $c->get_param('flagged') || 0, @@ -1217,13 +1215,13 @@ sub user_add : Path('user_edit') : Args(0) { key => 'users_email_key' } ); $c->stash->{user} = $user; + $c->forward('user_cobrand_extra_fields'); + $user->update; $c->forward( 'log_edit', [ $user->id, 'user', 'edit' ] ); - $c->stash->{status_message} = - '<p><em>' . _('Updated!') . '</em></p>'; - - return 1; + $c->flash->{status_message} = _("Updated!"); + $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); } sub user_edit : Path('user_edit') : Args(1) { @@ -1234,7 +1232,7 @@ sub user_edit : Path('user_edit') : Args(1) { my $user = $c->cobrand->users->find( { id => $id } ); $c->detach( '/page_error_404_not_found', [] ) unless $user; - unless ( $c->user->is_superuser || $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) { + unless ( $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) { $c->detach('/page_error_403_access_denied', []); } @@ -1247,12 +1245,18 @@ sub user_edit : Path('user_edit') : Args(1) { $c->forward('fetch_all_bodies'); $c->forward('fetch_body_areas', [ $user->from_body ]) if $user->from_body; + if ( defined $c->flash->{status_message} ) { + $c->stash->{status_message} = + '<p><em>' . $c->flash->{status_message} . '</em></p>'; + } + if ( $c->get_param('submit') ) { $c->forward('/auth/check_csrf_token'); my $edited = 0; - if ( $user->email ne $c->get_param('email') || + my $email = lc $c->get_param('email'); + if ( $user->email ne $email || $user->name ne $c->get_param('name') || ($user->phone || "") ne $c->get_param('phone') || ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) || @@ -1262,7 +1266,8 @@ sub user_edit : Path('user_edit') : Args(1) { } $user->name( $c->get_param('name') ); - $user->email( $c->get_param('email') ); + my $original_email = $user->email; + $user->email( $email ); $user->phone( $c->get_param('phone') ) if $c->get_param('phone'); $user->flagged( $c->get_param('flagged') || 0 ); # Only superusers can grant superuser status @@ -1278,6 +1283,8 @@ sub user_edit : Path('user_edit') : Args(1) { $user->from_body( undef ); } + $c->forward('user_cobrand_extra_fields'); + # Has the user's from_body changed since we fetched areas (if we ever did)? # If so, we need to re-fetch areas so the UI is up to date. if ( $user->from_body && $user->from_body->id ne $c->stash->{fetched_areas_body_id} ) { @@ -1361,19 +1368,24 @@ sub user_edit : Path('user_edit') : Args(1) { return if %{$c->stash->{field_errors}}; my $existing_user = $c->model('DB::User')->search({ email => $user->email, id => { '!=', $user->id } })->first; - if ($existing_user) { + my $existing_user_cobrand = $c->cobrand->users->search({ email => $user->email, id => { '!=', $user->id } })->first; + if ($existing_user_cobrand) { $existing_user->adopt($user); $c->forward( 'log_edit', [ $id, 'user', 'merge' ] ); $c->res->redirect( $c->uri_for( 'user_edit', $existing_user->id ) ); } else { + if ($existing_user) { + # Tried to change email to an existing one lacking permission + # so make sure it's switched back + $user->email($original_email); + } $user->update; if ($edited) { $c->forward( 'log_edit', [ $id, 'user', 'edit' ] ); } + $c->flash->{status_message} = _("Updated!"); + $c->res->redirect( $c->uri_for( 'user_edit', $user->id ) ); } - - $c->stash->{status_message} = - '<p><em>' . _('Updated!') . '</em></p>'; } if ( $user->from_body ) { @@ -1395,6 +1407,15 @@ sub user_edit : Path('user_edit') : Args(1) { return 1; } +sub user_cobrand_extra_fields : Private { + my ( $self, $c ) = @_; + + my @extra_fields = @{ $c->cobrand->call_hook('user_extra_fields') || [] }; + foreach ( @extra_fields ) { + $c->stash->{user}->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); + } +} + sub flagged : Path('flagged') : Args(0) { my ( $self, $c ) = @_; @@ -1465,7 +1486,7 @@ sub stats : Path('stats') : Args(0) { $selected_body = $c->user->from_body->id; } - if ( $c->cobrand->moniker eq 'seesomething' || $c->cobrand->moniker eq 'zurich' ) { + if ( $c->cobrand->moniker eq 'zurich' ) { return $c->cobrand->admin_stats(); } @@ -1612,7 +1633,7 @@ accordingly sub ban_user : Private { my ( $self, $c ) = @_; - my $email = $c->get_param('email'); + my $email = lc $c->get_param('email'); return unless $email; @@ -1639,7 +1660,7 @@ Sets the flag on a user with the given email sub flag_user : Private { my ( $self, $c ) = @_; - my $email = $c->get_param('email'); + my $email = lc $c->get_param('email'); return unless $email; @@ -1667,7 +1688,7 @@ Remove the flag on a user with the given email sub remove_user_flag : Private { my ( $self, $c ) = @_; - my $email = $c->get_param('email'); + my $email = lc $c->get_param('email'); return unless $email; diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm new file mode 100644 index 000000000..bcfeb3dd8 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm @@ -0,0 +1,113 @@ +package FixMyStreet::App::Controller::Admin::DefectTypes; +use Moose; +use namespace::autoclean; +use mySociety::ArrayUtils; + +BEGIN { extends 'Catalyst::Controller'; } + + +sub begin : Private { + my ( $self, $c ) = @_; + + $c->forward('/admin/begin'); +} + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + my $user = $c->user; + + if ($user->is_superuser) { + $c->forward('/admin/fetch_all_bodies'); + } elsif ( $user->from_body ) { + $c->forward('load_user_body', [ $user->from_body->id ]); + $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) ); + } else { + $c->detach( '/page_error_404_not_found' ); + } +} + +sub list : Path : Args(1) { + my ($self, $c, $body_id) = @_; + + $c->forward('load_user_body', [ $body_id ]); + + my @defect_types = $c->stash->{body}->defect_types->search( + undef, + { + order_by => 'name' + } + ); + + $c->stash->{defect_types} = \@defect_types; +} + +sub edit : Path : Args(2) { + my ( $self, $c, $body_id, $defect_type_id ) = @_; + + $c->forward('load_user_body', [ $body_id ]); + + my $defect_type; + if ($defect_type_id eq 'new') { + $defect_type = $c->stash->{body}->defect_types->new({}); + } + else { + $defect_type = $c->stash->{body}->defect_types->find( $defect_type_id ) + or $c->detach( '/page_error_404_not_found' ); + } + + $c->forward('/admin/fetch_contacts'); + my @contacts = $defect_type->contacts->all; + my @live_contacts = $c->stash->{live_contacts}->all; + my %active_contacts = map { $_->id => 1 } @contacts; + my @all_contacts = map { { + id => $_->id, + category => $_->category, + active => $active_contacts{$_->id}, + } } @live_contacts; + $c->stash->{contacts} = \@all_contacts; + + if ($c->req->method eq 'POST') { + $defect_type->name( $c->get_param('name') ); + $defect_type->description( $c->get_param('description') ); + + my @extra_fields = @{ $c->cobrand->call_hook('defect_type_extra_fields') || [] }; + foreach ( @extra_fields ) { + $defect_type->set_extra_metadata( $_ => $c->get_param("extra[$_]") ); + } + + $defect_type->update_or_insert; + my @live_contact_ids = map { $_->id } @live_contacts; + my @new_contact_ids = $c->get_param_list('categories'); + @new_contact_ids = @{ mySociety::ArrayUtils::intersection(\@live_contact_ids, \@new_contact_ids) }; + $defect_type->contact_defect_types->search({ + contact_id => { '!=' => \@new_contact_ids }, + })->delete; + foreach my $contact_id (@new_contact_ids) { + $defect_type->contact_defect_types->find_or_create({ + contact_id => $contact_id, + }); + } + + $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) ); + } + + $c->stash->{defect_type} = $defect_type; +} + +sub load_user_body : Private { + my ($self, $c, $body_id) = @_; + + my $has_permission = $c->user->has_body_permission_to('defect_type_edit', $body_id); + + unless ( $has_permission ) { + $c->detach( '/page_error_404_not_found' ); + } + + $c->stash->{body} = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found' ); +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm new file mode 100644 index 000000000..201742c81 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm @@ -0,0 +1,230 @@ +package FixMyStreet::App::Controller::Admin::ExorDefects; +use Moose; +use namespace::autoclean; + +use Text::CSV; +use DateTime; +use mySociety::Random qw(random_bytes); + +BEGIN { extends 'Catalyst::Controller'; } + + +sub begin : Private { + my ( $self, $c ) = @_; + + $c->forward('/admin/begin'); +} + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + foreach (qw(error_message start_date end_date user_id)) { + if ( defined $c->flash->{$_} ) { + $c->stash->{$_} = $c->flash->{$_}; + } + } + + my @inspectors = $c->cobrand->users->search({ + 'user_body_permissions.permission_type' => 'report_inspect' + }, { + join => 'user_body_permissions', + distinct => 1, + } + )->all; + $c->stash->{inspectors} = \@inspectors; + + # Default start/end date is today + my $now = DateTime->now( time_zone => + FixMyStreet->time_zone || FixMyStreet->local_time_zone ); + $c->stash->{start_date} ||= $now; + $c->stash->{end_date} ||= $now; + +} + +sub download : Path('download') : Args(0) { + my ( $self, $c ) = @_; + + if ( !$c->cobrand->can('exor_rdi_link_id') ) { + # This only works on the Oxfordshire cobrand currently. + $c->detach( '/page_error_404_not_found', [] ); + } + + my $parser = DateTime::Format::Strptime->new( pattern => '%d/%m/%Y' ); + my $start_date = $parser-> parse_datetime ( $c->get_param('start_date') ); + my $end_date = $parser-> parse_datetime ( $c->get_param('end_date') ) ; + my $one_day = DateTime::Duration->new( days => 1 ); + + my %params = ( + -and => [ + state => [ 'action scheduled' ], + external_id => { '!=' => undef }, + 'admin_log_entries.action' => 'inspected', + 'admin_log_entries.whenedited' => { '>=', $start_date }, + 'admin_log_entries.whenedited' => { '<=', $end_date + $one_day }, + ] + ); + + my $user; + if ( $c->get_param('user_id') ) { + my $uid = $c->get_param('user_id'); + $params{'admin_log_entries.user_id'} = $uid; + $user = $c->model('DB::User')->find( { id => $uid } ); + } + + my $problems = $c->cobrand->problems->search( + \%params, + { + join => 'admin_log_entries', + distinct => 1, + } + ); + + if ( !$problems->count ) { + if ( defined $user ) { + $c->flash->{error_message} = _("No inspections by that inspector in the selected date range."); + } else { + $c->flash->{error_message} = _("No inspections in the selected date range."); + } + $c->flash->{start_date} = $start_date; + $c->flash->{end_date} = $end_date; + $c->flash->{user_id} = $user->id if $user; + $c->res->redirect( $c->uri_for( '' ) ); + } + + # A single RDI file might contain inspections from multiple inspectors, so + # we need to group inspections by inspector within G records. + my $inspectors = {}; + my $inspector_initials = {}; + while ( my $report = $problems->next ) { + my $user = $report->inspection_log_entry->user; + $inspectors->{$user->id} ||= []; + push @{ $inspectors->{$user->id} }, $report; + unless ( $inspector_initials->{$user->id} ) { + $inspector_initials->{$user->id} = $user->get_extra_metadata('initials'); + } + } + + my $csv = Text::CSV->new({ binary => 1, eol => "" }); + + my $p_count = 0; + my $link_id = $c->cobrand->exor_rdi_link_id; + + # RDI first line is always the same + $csv->combine("1", "1.8", "1.0.0.0", "ENHN", ""); + my @body = ($csv->string); + + my $i = 0; + foreach my $inspector_id (keys %$inspectors) { + my $inspections = $inspectors->{$inspector_id}; + my $initials = $inspector_initials->{$inspector_id}; + + $csv->combine( + "G", # start of an area/sequence + $link_id, # area/link id, fixed value for our purposes + "","", # must be empty + $initials || "XX", # inspector initials + $start_date->strftime("%y%m%d"), # date of inspection yymmdd + "0700", # time of inspection hhmm, set to static value for now + "D", # inspection variant, should always be D + "INS", # inspection type, always INS + "N", # Area of the county - north (N) or south (S) + "", "", "", "" # empty fields + ); + push @body, $csv->string; + + $csv->combine( + "H", # initial inspection type + "MC" # minor carriageway (changes depending on activity code) + ); + push @body, $csv->string; + + foreach my $report (@$inspections) { + my ($eastings, $northings) = $report->local_coords; + my $description = sprintf("%s %s", $report->external_id || "", $report->get_extra_metadata('detailed_information') || ""); + my $activity_code = $report->defect_type ? + $report->defect_type->get_extra_metadata('activity_code') + : 'MC'; + my $traffic_information = $report->get_extra_metadata('traffic_information') ? + 'TM ' . $report->get_extra_metadata('traffic_information') + : 'TM none'; + + $csv->combine( + "I", # beginning of defect record + $activity_code, # activity code - minor carriageway, also FC (footway) + "", # empty field, can also be A (seen on MC) or B (seen on FC) + sprintf("%03d", ++$i), # randomised sequence number + "${eastings}E ${northings}N", # defect location field, which we don't capture from inspectors + $report->inspection_log_entry->whenedited->strftime("%H%M"), # defect time raised + "","","","","","","", # empty fields + $traffic_information, + $description, # defect description + ); + push @body, $csv->string; + + my $defect_type = $report->defect_type ? + $report->defect_type->get_extra_metadata('defect_code') + : 'SFP2'; + $csv->combine( + "J", # georeferencing record + $defect_type, # defect type - SFP2: sweep and fill <1m2, POT2 also seen + $report->response_priority ? + $report->response_priority->external_id : + "2", # priority of defect + "","", # empty fields + $eastings, # eastings + $northings, # northings + "","","","","" # empty fields + ); + push @body, $csv->string; + + $csv->combine( + "M", # bill of quantities record + "resolve", # permanent repair + "","", # empty fields + "/CMC", # /C + activity code + "", "" # empty fields + ); + push @body, $csv->string; + } + + # end this group of defects with a P record + $csv->combine( + "P", # end of area/sequence + 0, # always 0 + 999999, # charging code, always 999999 in OCC + ); + push @body, $csv->string; + $p_count++; + } + + # end the RDI file with an X record + my $record_count = $i; + $csv->combine( + "X", # end of inspection record + $p_count, + $p_count, + $record_count, # number of I records + $record_count, # number of J records + 0, 0, 0, # always zero + $record_count, # number of M records + 0, # always zero + $p_count, + 0, 0, 0 # error counts, always zero + ); + push @body, $csv->string; + + my $start = $start_date->strftime("%Y%m%d"); + my $end = $end_date->strftime("%Y%m%d"); + my $filename = sprintf("exor_defects-%s-%s.rdi", $start, $end); + if ( $user ) { + my $initials = $user->get_extra_metadata("initials") || ""; + $filename = sprintf("exor_defects-%s-%s-%s.rdi", $start, $end, $initials); + } + $c->res->content_type('text/csv; charset=utf-8'); + $c->res->header('content-disposition' => "attachment; filename=$filename"); + # The RDI format is very weird CSV - each line must be wrapped in + # double quotes. + $c->res->body( join "", map { "\"$_\"\r\n" } @body ); +} + +1;
\ No newline at end of file diff --git a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm index 032e593c6..bae0f71a7 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm @@ -70,6 +70,7 @@ sub edit : Path : Args(2) { $priority->deleted( $c->get_param('deleted') ? 1 : 0 ); $priority->name( $c->get_param('name') ); $priority->description( $c->get_param('description') ); + $priority->external_id( $c->get_param('external_id') ); $priority->update_or_insert; my @live_contact_ids = map { $_->id } @live_contacts; @@ -92,10 +93,9 @@ sub edit : Path : Args(2) { sub load_user_body : Private { my ($self, $c, $body_id) = @_; - my $has_permission = $c->user->has_body_permission_to('responsepriority_edit') && - $c->user->from_body->id eq $body_id; + my $has_permission = $c->user->has_body_permission_to('responsepriority_edit', $body_id); - unless ( $c->user->is_superuser || $has_permission ) { + unless ( $has_permission ) { $c->detach( '/page_error_404_not_found' ); } diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index b4f94bb35..1fe35d0a3 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -182,7 +182,7 @@ sub display_location : Private { my ( $on_map_all, $on_map, $nearby, $distance ) = FixMyStreet::Map::map_features( $c, latitude => $latitude, longitude => $longitude, - interval => $interval, categories => $c->stash->{filter_category}, + interval => $interval, categories => [ keys %{$c->stash->{filter_category}} ], states => $c->stash->{filter_problem_states}, order => $c->stash->{sort_order}, ); @@ -264,8 +264,8 @@ sub check_and_stash_category : Private { my %categories_mapped = map { $_ => 1 } @categories; my $categories = [ $c->get_param_list('filter_category', 1) ]; - my @valid_categories = grep { $_ && $categories_mapped{$_} } @$categories; - $c->stash->{filter_category} = \@valid_categories; + my %valid_categories = map { $_ => 1 } grep { $_ && $categories_mapped{$_} } @$categories; + $c->stash->{filter_category} = \%valid_categories; } =head2 /ajax @@ -291,6 +291,8 @@ sub ajax : Path('/ajax') { # assume this is not cacheable - may need to be more fine-grained later $c->res->header( 'Cache_Control' => 'max-age=0' ); + $c->stash->{page} = 'around'; # Needed by _item.html + # how far back should we go? my $all_pins = $c->get_param('all_pins') ? 1 : undef; my $interval = $all_pins ? undef : $c->cobrand->on_map_default_max_pin_age; diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 40cd163cf..4efa7abb8 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -70,6 +70,7 @@ sub sign_in : Private { my ( $self, $c, $email ) = @_; $email ||= $c->get_param('email') || ''; + $email = lc $email; my $password = $c->get_param('password_sign_in') || ''; my $remember_me = $c->get_param('remember_me') || 0; @@ -103,7 +104,7 @@ sub sign_in : Private { Email the user the details they need to sign in. Don't check for an account - if there isn't one we can create it when they come back with a token (which -contains the email addresss). +contains the email address). =cut @@ -222,7 +223,7 @@ sub token : Path('/M') : Args(1) { $c->authenticate( { email => $user->email }, 'no_password' ); # send the user to their page - $c->detach( 'redirect_on_signin', [ $data->{r} ] ); + $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] ); } =head2 facebook_sign_in @@ -271,9 +272,8 @@ sub facebook_callback: Path('/auth/Facebook') : Args(0) { $access_token = $fb->get_access_token(code => $c->get_param('code')); }; if ($@) { - ($c->stash->{message} = $@) =~ s/at [^ ]*Auth.pm.*//; - $c->stash->{template} = 'errors/generic.html'; - $c->detach; + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); } # save this token in session @@ -339,9 +339,8 @@ sub twitter_callback: Path('/auth/Twitter') : Args(0) { $twitter->request_access_token(verifier => $verifier); }; if ($@) { - ($c->stash->{message} = $@) =~ s/at [^ ]*Auth.pm.*//; - $c->stash->{template} = 'errors/generic.html'; - $c->detach; + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); } my $info = $twitter->verify_credentials(); @@ -412,13 +411,36 @@ Used after signing in to take the person back to where they were. sub redirect_on_signin : Private { - my ( $self, $c, $redirect ) = @_; - $redirect = 'my' unless $redirect; - $redirect = 'my' if $redirect =~ /^admin/ && !$c->user->is_superuser; + my ( $self, $c, $redirect, $params ) = @_; + unless ( $redirect ) { + $c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories }; + $redirect = 'my'; + } + $redirect = 'my' if $redirect =~ /^admin/ && !$c->cobrand->admin_allow_user($c->user); if ( $c->cobrand->moniker eq 'zurich' ) { $redirect = 'admin' if $c->user->from_body; } - $c->res->redirect( $c->uri_for( "/$redirect" ) ); + if (defined $params) { + $c->res->redirect( $c->uri_for( "/$redirect", $params ) ); + } else { + $c->res->redirect( $c->uri_for( "/$redirect" ) ); + } +} + +=head2 redirect_to_categories + +Redirects the user to their body's reports page, prefiltered to whatever +categories this user has been assigned to. + +=cut + +sub redirect_to_categories : Private { + my ( $self, $c ) = @_; + + my $categories = join(',', @{ $c->user->categories }); + my $body_short = $c->cobrand->short_name( $c->user->from_body ); + + $c->res->redirect( $c->uri_for( "/reports/" . $body_short, { filter_category => $categories } ) ); } =head2 redirect @@ -518,17 +540,17 @@ sub check_csrf_token : Private { $token =~ s/ /+/g; my ($time) = $token =~ /^(\d+)-[0-9a-zA-Z+\/]+$/; $c->stash->{csrf_time} = $time; + my $gen_token = $c->forward('get_csrf_token'); + delete $c->stash->{csrf_time}; $c->detach('no_csrf_token') unless $time && $time > time() - 3600 - && $token eq $c->forward('get_csrf_token'); - delete $c->stash->{csrf_time}; + && $token eq $gen_token; } sub no_csrf_token : Private { my ($self, $c) = @_; - $c->stash->{message} = _('Unknown error'); - $c->stash->{template} = 'errors/generic.html'; + $c->detach('/page_error_400_bad_request', []); } =head2 sign_out diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index 9189b28e5..fbe5a2dc9 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -57,9 +57,9 @@ sub example : Local : Args(0) { } }; if ($@) { - $c->stash->{message} = _("There was a problem showing this page. Please try again later.") . ' ' . + my $message = _("There was a problem showing this page. Please try again later.") . ' ' . sprintf(_('The error was: %s'), $@); - $c->stash->{template} = 'errors/generic.html'; + $c->detach('/page_error_500_internal_error', [ $message ]); } } diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index 94e6cd62a..74f2e6b31 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -83,6 +83,11 @@ sub moderate_report : Chained('report') : PathPart('') : Args(0) { $c->detach( 'report_moderate_audit', \@types ) } +sub moderating_user_name { + my $user = shift; + return $user->from_body ? $user->from_body->name : 'a FixMyStreet administrator'; +} + sub report_moderate_audit : Private { my ($self, $c, @types) = @_; @@ -95,7 +100,7 @@ sub report_moderate_audit : Private { $c->model('DB::AdminLog')->create({ action => 'moderation', user => $user, - admin_user => $user->name, + admin_user => moderating_user_name($user), object_id => $problem->id, object_type => 'problem', reason => (sprintf '%s (%s)', $reason, $types_csv), @@ -249,7 +254,7 @@ sub update_moderate_audit : Private { $c->model('DB::AdminLog')->create({ action => 'moderation', user => $user, - admin_user => $user->name, + admin_user => moderating_user_name($user), object_id => $comment->id, object_type => 'update', reason => (sprintf '%s (%s)', $reason, $types_csv), diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm index 51f1687ee..77711f807 100644 --- a/perllib/FixMyStreet/App/Controller/My.pm +++ b/perllib/FixMyStreet/App/Controller/My.pm @@ -3,6 +3,7 @@ use Moose; use namespace::autoclean; use JSON::MaybeXS; +use List::MoreUtils qw(first_index); BEGIN { extends 'Catalyst::Controller'; } @@ -30,8 +31,11 @@ sub begin : Private { sub my : Path : Args(0) { my ( $self, $c ) = @_; + $c->forward('/auth/get_csrf_token'); + $c->stash->{problems_rs} = $c->cobrand->problems->search( { user_id => $c->user->id }); + $c->forward('/reports/stash_report_sort', [ 'created-desc' ]); $c->forward('get_problems'); if ($c->get_param('ajax')) { $c->detach('/reports/ajax', [ 'my/_problem-list.html' ]); @@ -43,21 +47,58 @@ sub my : Path : Args(0) { sub planned : Local : Args(0) { my ( $self, $c ) = @_; + $c->forward('/auth/get_csrf_token'); + $c->detach('/page_error_403_access_denied', []) unless $c->user->has_body_permission_to('planned_reports'); $c->stash->{problems_rs} = $c->user->active_planned_reports; + $c->forward('planned_reorder'); + $c->forward('/reports/stash_report_sort', [ 'shortlist' ]); $c->forward('get_problems'); + if ($c->get_param('ajax')) { + $c->stash->{shortlist} = $c->stash->{sort_key} eq 'shortlist'; + $c->detach('/reports/ajax', [ 'my/_problem-list.html' ]); + } $c->forward('setup_page_data'); } +sub planned_reorder : Private { + my ($self, $c) = @_; + + my @extra = grep { /^shortlist-(up|down|\d+)$/ } keys %{$c->req->params}; + return unless @extra; + my ($reorder) = $extra[0] =~ /^shortlist-(up|down|\d+)$/; + + my @shortlist = sort by_shortlisted $c->stash->{problems_rs}->all; + + # Find where moving problem ID is + my $id = $c->get_param('id') || return; + my $curr_index = first_index { $_->id == $id } @shortlist; + return unless $curr_index > -1; + + if ($reorder eq 'up' && $curr_index > 0) { + @shortlist[$curr_index-1,$curr_index] = @shortlist[$curr_index,$curr_index-1]; + } elsif ($reorder eq 'down' && $curr_index < @shortlist-1) { + @shortlist[$curr_index,$curr_index+1] = @shortlist[$curr_index+1,$curr_index]; + } elsif ($reorder >= 0 && $reorder <= @shortlist-1) { # Must be an index to move it + @shortlist[$curr_index,$reorder] = @shortlist[$reorder,$curr_index]; + } + + # Store new ordering + my $i = 1; + foreach (@shortlist) { + $_->set_extra_metadata('order', $i++); + $_->update; + } +} + sub get_problems : Private { my ($self, $c) = @_; my $p_page = $c->get_param('p') || 1; $c->forward( '/reports/stash_report_filter_status' ); - $c->forward('/reports/stash_report_sort', [ 'created-desc' ]); my $pins = []; my $problems = []; @@ -70,12 +111,15 @@ sub get_problems : Private { my $categories = [ $c->get_param_list('filter_category', 1) ]; if ( @$categories ) { $params->{category} = $categories; - $c->stash->{filter_category} = $categories; + $c->stash->{filter_category} = { map { $_ => 1 } @$categories }; } + my $rows = 50; + $rows = 5000 if $c->stash->{sort_key} eq 'shortlist'; # Want all reports + my $rs = $c->stash->{problems_rs}->search( $params, { order_by => $c->stash->{sort_order}, - rows => 50 + rows => $rows, } )->include_comment_counts->page( $p_page ); while ( my $problem = $rs->next ) { @@ -83,6 +127,9 @@ sub get_problems : Private { push @$pins, $problem->pin_data($c, 'my', private => 1); push @$problems, $problem; } + + @$problems = sort by_shortlisted @$problems if $c->stash->{sort_key} eq 'shortlist'; + $c->stash->{problems_pager} = $rs->pager; $c->stash->{problems} = $problems; $c->stash->{pins} = $pins; @@ -134,27 +181,45 @@ sub planned_change : Path('planned/change') { my ($self, $c) = @_; $c->forward('/auth/check_csrf_token'); + $c->go('planned') if grep { /^shortlist-(up|down|\d+)$/ } keys %{$c->req->params}; + my $id = $c->get_param('id'); $c->forward( '/report/load_problem_or_display_error', [ $id ] ); - my $change = $c->get_param('change'); + my $add = $c->get_param('shortlist-add'); + my $remove = $c->get_param('shortlist-remove'); $c->detach('/page_error_403_access_denied', []) - unless $change && $change =~ /add|remove/; + unless $add || $remove; - if ($change eq 'add') { + if ($add) { $c->user->add_to_planned_reports($c->stash->{problem}); - } elsif ($change eq 'remove') { + } elsif ($remove) { $c->user->remove_from_planned_reports($c->stash->{problem}); } if ($c->get_param('ajax')) { $c->res->content_type('application/json; charset=utf-8'); - $c->res->body(encode_json({ outcome => $change })); + $c->res->body(encode_json({ outcome => $add ? 'add' : 'remove' })); } else { $c->res->redirect( $c->uri_for_action('report/display', $id) ); } } +sub by_shortlisted { + my $a_order = $a->get_extra_metadata('order') || 0; + my $b_order = $b->get_extra_metadata('order') || 0; + if ($a_order && $b_order) { + $a_order <=> $b_order; + } elsif ($a_order) { + -1; # Want non-ordered to come last + } elsif ($b_order) { + 1; # Want non-ordered to come last + } else { + # Default to order added to planned reports + $a->user_planned_reports->first->id <=> $b->user_planned_reports->first->id; + } +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm new file mode 100644 index 000000000..dceccc81f --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Offline.pm @@ -0,0 +1,47 @@ +package FixMyStreet::App::Controller::Offline; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Offline - Catalyst Controller + +=head1 DESCRIPTION + +Offline pages Catalyst Controller. +On staging site, appcache only for people who want it. + +=head1 METHODS + +=cut + +sub have_appcache : Private { + my ($self, $c) = @_; + return $c->user_exists && $c->user->has_body_permission_to('planned_reports') + && !FixMyStreet->staging_flag('enable_appcache', 0); +} + +sub manifest : Path("/offline/appcache.manifest") { + my ($self, $c) = @_; + unless ($c->forward('have_appcache')) { + $c->response->status(404); + $c->response->body('NOT FOUND'); + } + $c->res->content_type('text/cache-manifest; charset=utf-8'); + $c->res->header(Cache_Control => 'no-cache, no-store'); +} + +sub appcache : Path("/offline/appcache") { + my ($self, $c) = @_; + $c->detach('/page_error_404_not_found', []) if keys %{$c->req->params}; + unless ($c->forward('have_appcache')) { + $c->response->status(404); + $c->response->body('NOT FOUND'); + } +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Open311.pm b/perllib/FixMyStreet/App/Controller/Open311.pm index 98e5f42b2..bc08593de 100644 --- a/perllib/FixMyStreet/App/Controller/Open311.pm +++ b/perllib/FixMyStreet/App/Controller/Open311.pm @@ -233,44 +233,42 @@ sub output_requests : Private { my $request = { - 'service_request_id' => [ $id ], - 'title' => [ $problem->title ], # Not in Open311 v2 - 'detail' => [ $problem->detail ], # Not in Open311 v2 - 'description' => [ $problem->title .': ' . $problem->detail ], - 'lat' => [ $problem->latitude ], - 'long' => [ $problem->longitude ], - 'status' => [ $problem->state ], -# 'status_notes' => [ {} ], - 'requested_datetime' => [ w3date($problem->confirmed) ], - 'updated_datetime' => [ w3date($problem->lastupdate) ], -# 'expected_datetime' => [ {} ], -# 'address' => [ {} ], -# 'address_id' => [ {} ], - 'service_code' => [ $problem->category ], - 'service_name' => [ $problem->category ], -# 'service_notice' => [ {} ], -# 'zipcode' => [ {} ], - 'interface_used' => [ $problem->service ], # Not in Open311 v2 + 'service_request_id' => $id, + 'title' => $problem->title, # Not in Open311 v2 + 'detail' => $problem->detail, # Not in Open311 v2 + 'description' => $problem->title .': ' . $problem->detail, + 'lat' => $problem->latitude, + 'long' => $problem->longitude, + 'status' => $problem->state, +# 'status_notes' => {}, + 'requested_datetime' => w3date($problem->confirmed), + 'updated_datetime' => w3date($problem->lastupdate), +# 'expected_datetime' => {}, +# 'address' => {}, +# 'address_id' => {}, + 'service_code' => $problem->category, + 'service_name' => $problem->category, +# 'service_notice' => {}, +# 'zipcode' => {}, + 'interface_used' => $problem->service, # Not in Open311 v2 }; if ( $c->cobrand->moniker eq 'zurich' ) { - $request->{service_notice} = [ - $problem->get_extra_metadata('public_response') - ]; + $request->{service_notice} = $problem->get_extra_metadata('public_response'); } else { # FIXME Not according to Open311 v2 - $request->{agency_responsible} = $problem->bodies; + my @body_names = map { $_->name } values %{$problem->bodies}; + $request->{agency_responsible} = {'recipient' => [ @body_names ] }; } if ( !$problem->anonymous ) { # Not in Open311 v2 - $request->{'requestor_name'} = [ $problem->name ]; + $request->{'requestor_name'} = $problem->name; } if ( $problem->whensent ) { # Not in Open311 v2 - $request->{'agency_sent_datetime'} = - [ w3date($problem->whensent) ]; + $request->{'agency_sent_datetime'} = w3date($problem->whensent); } # Extract number of updates @@ -279,25 +277,18 @@ sub output_requests : Private { )->count; if ($updates) { # Not in Open311 v2 - $request->{'comment_count'} = [ $updates ]; + $request->{'comment_count'} = $updates; } my $display_photos = $c->cobrand->allow_photo_display($problem); if ($display_photos && $problem->photo) { my $url = $c->cobrand->base_url(); my $imgurl = $url . $problem->photos->[0]->{url_full}; - $request->{'media_url'} = [ $imgurl ]; + $request->{'media_url'} = $imgurl; } push(@problemlist, $request); } - foreach my $request (@problemlist) { - if ($request->{agency_responsible}) { - my @body_names = map { $_->name } values %{$request->{agency_responsible}} ; - $request->{agency_responsible} = - [ {'recipient' => [ @body_names ] } ]; - } - } $c->forward( 'format_output', [ { 'requests' => [ { 'request' => \@problemlist @@ -432,7 +423,7 @@ sub format_output : Private { $c->res->body( encode_json($hashref) ); } elsif ('xml' eq $format) { $c->res->content_type('application/xml; charset=utf-8'); - $c->res->body( XMLout($hashref, RootName => undef) ); + $c->res->body( XMLout($hashref, RootName => undef, NoAttr => 1 ) ); } else { $c->detach( 'error', [ sprintf(_('Invalid format %s specified.'), $format) diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm index 017a552db..1b338732b 100755 --- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm +++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm @@ -36,9 +36,8 @@ sub check_questionnaire : Private { if ( $questionnaire->whenanswered ) { my $problem_url = $c->cobrand->base_url_for_report( $problem ) . $problem->url; my $contact_url = $c->uri_for( "/contact" ); - $c->stash->{message} = sprintf(_("You have already answered this questionnaire. If you have a question, please <a href='%s'>get in touch</a>, or <a href='%s'>view your problem</a>.\n"), $contact_url, $problem_url); - $c->stash->{template} = 'errors/generic.html'; - $c->detach; + my $message = sprintf(_("You have already answered this questionnaire. If you have a question, please <a href='%s'>get in touch</a>, or <a href='%s'>view your problem</a>.\n"), $contact_url, $problem_url); + $c->detach('/page_error_400_bad_request', [ $message ]); } unless ( $problem->is_visible ) { @@ -86,8 +85,8 @@ Display couldn't locate problem error message sub missing_problem : Private { my ( $self, $c ) = @_; - $c->stash->{message} = _("I'm afraid we couldn't locate your problem in the database.\n"); - $c->stash->{template} = 'errors/generic.html'; + my $message = _("I'm afraid we couldn't locate your problem in the database.\n"); + $c->detach('/page_error_400_bad_request', [ $message ]); } sub submit_creator_fixed : Private { diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 5a1cfbe54..ad2702460 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -275,7 +275,8 @@ sub delete :Local :Args(1) { $p->user->update_reputation(-1); $c->model('DB::AdminLog')->create( { - admin_user => $c->user->email, + user => $c->user->obj, + admin_user => $c->user->from_body->name, object_type => 'problem', action => 'state_change', object_id => $id, @@ -305,7 +306,7 @@ sub inspect : Private { my $problem = $c->stash->{problem}; my $permissions = $c->stash->{_permissions}; - $c->stash->{categories} = $c->forward('/admin/categories_for_point'); + $c->forward('/admin/categories_for_point'); $c->stash->{report_meta} = { map { $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } }; my %category_body = map { $_->category => $_->body_id } map { $_->contacts->all } values %{$problem->bodies}; @@ -343,12 +344,15 @@ sub inspect : Private { $problem->set_extra_metadata( $_ => $c->get_param($_) ); } - if ( $c->get_param('save_inspected') ) { + if ( $c->get_param('defect_type') ) { + $problem->defect_type($problem->defect_types->find($c->get_param('defect_type'))); + } else { + $problem->defect_type(undef); + } + + if ( $c->get_param('include_update') ) { $update_text = Utils::cleanup_text( $c->get_param('public_update'), { allow_multiline => 1 } ); - if ($update_text) { - $problem->set_extra_metadata( inspected => 1 ); - $reputation_change = 1; - } else { + if (!$update_text) { $valid = 0; $c->stash->{errors} ||= []; push @{ $c->stash->{errors} }, _('Please provide a public update for this report.'); @@ -374,6 +378,16 @@ sub inspect : Private { } if ( $problem->state ne $old_state ) { $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'state_change' ] ); + + # If the state has been changed by an inspector, consider the + # report to be inspected. + unless ($problem->get_extra_metadata('inspected')) { + $problem->set_extra_metadata( inspected => 1 ); + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'inspected' ] ); + my $state = $problem->state; + $reputation_change = 1 if $c->cobrand->reputation_increment_states->{$state}; + $reputation_change = -1 if $c->cobrand->reputation_decrement_states->{$state}; + } } } @@ -408,12 +422,17 @@ sub inspect : Private { $problem->lastupdate( \'current_timestamp' ); $problem->update; if ( defined($update_text) ) { + my $timestamp = \'current_timestamp'; + if (my $saved_at = $c->get_param('saved_at')) { + $timestamp = DateTime->from_epoch( epoch => $saved_at ); + } + my $name = $c->user->from_body ? $c->user->from_body->name : $c->user->name; $problem->add_to_comments( { text => $update_text, - created => \'current_timestamp', - confirmed => \'current_timestamp', + created => $timestamp, + confirmed => $timestamp, user_id => $c->user->id, - name => $c->user->from_body->name, + name => $name, state => 'confirmed', mark_fixed => 0, anonymous => 0, @@ -429,6 +448,12 @@ sub inspect : Private { } else { $redirect_uri = $c->uri_for( $problem->url ); } + + # Or if inspector, redirect back to shortlist + if ($c->user->has_body_permission_to('planned_reports')) { + $redirect_uri = $c->uri_for_action('my/planned'); + } + $c->log->debug( "Redirecting to: " . $redirect_uri ); $c->res->redirect( $redirect_uri ); } diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index e2569d2e9..2a68b170e 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -83,6 +83,14 @@ sub report_new : Path : Args(0) { $c->forward('initialize_report'); $c->forward('/auth/get_csrf_token'); + my @shortlist = grep { /^shortlist-(add|remove)-(\d+)$/ } keys %{$c->req->params}; + if (@shortlist) { + my ($cmd, $id) = $shortlist[0] =~ /^shortlist-(add|remove)-(\d+)$/; + $c->req->params->{id} = $id; + $c->req->params->{"shortlist-$cmd"} = 1; + $c->detach('/my/planned_change'); + } + # work out the location for this report and do some checks # Also show map if we're just updating the filters return $c->forward('redirect_to_around') @@ -651,8 +659,7 @@ sub setup_categories_and_bodies : Private { push @category_options, _('Other') if $seen{_('Other')}; } - $c->cobrand->munge_category_list(\@category_options, \@contacts, \%category_extras) - if $c->cobrand->can('munge_category_list'); + $c->cobrand->call_hook(munge_category_list => \@category_options, \@contacts, \%category_extras); # put results onto stash for display $c->stash->{bodies} = \%bodies; @@ -895,7 +902,7 @@ sub contacts_to_bodies : Private { if ($c->stash->{unresponsive}{$category} || $c->stash->{unresponsive}{ALL}) { []; } else { - if ( $c->cobrand->can('singleton_bodies_str') && $c->cobrand->singleton_bodies_str ) { + if ( $c->cobrand->call_hook('singleton_bodies_str') ) { # Cobrands like Zurich can only ever have a single body: 'x', because some functionality # relies on string comparison against bodies_str. [ $contacts[0]->body ]; @@ -1025,9 +1032,7 @@ sub send_problem_confirm_email : Private { $template = 'problem-confirm-not-sending.txt' unless $report->bodies_str; $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->cobrand->call_hook(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 813c2052d..ed851f71f 100644 --- a/perllib/FixMyStreet/App/Controller/Reports.pm +++ b/perllib/FixMyStreet/App/Controller/Reports.pm @@ -76,13 +76,12 @@ sub index : Path : Args(0) { $c->stash->{open} = $j->{open}; }; if ($@) { - $c->stash->{message} = _("There was a problem showing the All Reports page. Please try again later."); + my $message = _("There was a problem showing the All Reports page. Please try again later."); if ($c->config->{STAGING_SITE}) { - $c->stash->{message} .= '</p><p>Perhaps the bin/update-all-reports script needs running. Use: bin/update-all-reports</p><p>' + $message .= '</p><p>Perhaps the bin/update-all-reports script needs running. Use: bin/update-all-reports</p><p>' . sprintf(_('The error was: %s'), $@); } - $c->stash->{template} = 'errors/generic.html'; - return; + $c->detach('/page_error_500_internal_error', [ $message ]); } # Down here so that error pages aren't cached. @@ -109,6 +108,8 @@ Show the summary page for a particular ward. sub ward : Path : Args(2) { my ( $self, $c, $body, $ward ) = @_; + $c->forward('/auth/get_csrf_token'); + $c->forward( 'body_check', [ $body ] ); $c->forward( 'ward_check', [ $ward ] ) if $ward; @@ -136,7 +137,7 @@ sub ward : Path : Args(2) { } )->all; @categories = map { $_->category } @categories; $c->stash->{filter_categories} = \@categories; - $c->stash->{filter_category} = [ $c->get_param_list('filter_category', 1) ]; + $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) }; my $pins = $c->stash->{pins}; @@ -377,6 +378,25 @@ sub load_and_group_problems : Private { non_public => 0, state => [ keys %$states ] }; + my $filter = { + order_by => $c->stash->{sort_order}, + rows => $c->cobrand->reports_per_page, + }; + + if (defined $c->stash->{filter_status}{shortlisted}) { + $where->{'me.id'} = { '=', \"user_planned_reports.report_id"}; + $where->{'user_planned_reports.removed'} = undef; + $filter->{join} = 'user_planned_reports'; + } elsif (defined $c->stash->{filter_status}{unshortlisted}) { + my $shortlisted_ids = $c->cobrand->problems->search({ + 'me.id' => { '=', \"user_planned_reports.report_id"}, + 'user_planned_reports.removed' => undef, + }, { + join => 'user_planned_reports', + columns => ['me.id'], + })->as_query; + $where->{'me.id'} = { -not_in => $shortlisted_ids }; + } my $not_open = [ FixMyStreet::DB::Result::Problem::fixed_states(), FixMyStreet::DB::Result::Problem::closed_states() ]; if ( $type eq 'new' ) { @@ -410,13 +430,17 @@ sub load_and_group_problems : Private { $problems = $problems->to_body($c->stash->{body}); } + if (my $bbox = $c->get_param('bbox')) { + my ($min_lon, $min_lat, $max_lon, $max_lat) = split /,/, $bbox; + $where->{latitude} = { '>=', $min_lat, '<', $max_lat }; + $where->{longitude} = { '>=', $min_lon, '<', $max_lon }; + } + $problems = $problems->search( $where, - { - order_by => $c->stash->{sort_order}, - rows => $c->cobrand->reports_per_page, - } + $filter )->include_comment_counts->page( $page ); + $c->stash->{pager} = $problems->pager; my ( %problems, @pins ); @@ -499,6 +523,19 @@ sub stash_report_filter_status : Private { %filter_problem_states = %$s; } + if ($status{shortlisted}) { + $filter_status{shortlisted} = 1; + } + + if ($status{unshortlisted}) { + $filter_status{unshortlisted} = 1; + } + + if (keys %filter_problem_states == 0) { + my $s = FixMyStreet::DB::Result::Problem->open_states(); + %filter_problem_states = (%filter_problem_states, %$s); + } + $c->stash->{filter_problem_states} = \%filter_problem_states; $c->stash->{filter_status} = \%filter_status; return 1; @@ -514,13 +551,17 @@ sub stash_report_sort : Private { ); my $sort = $c->get_param('sort') || $default; - $sort = $default unless $sort =~ /^((updated|created)-(desc|asc)|comments-desc)$/; + $sort = $default unless $sort =~ /^((updated|created)-(desc|asc)|comments-desc|shortlist)$/; + $c->stash->{sort_key} = $sort; + + # Going to do this sorting code-side + $sort = 'created-desc' if $sort eq 'shortlist'; + $sort =~ /^(updated|created|comments)-(desc|asc)$/; my $order_by = $types{$1} || $1; my $dir = $2; $order_by = { -desc => $order_by } if $dir eq 'desc'; - $c->stash->{sort_key} = $sort; $c->stash->{sort_order} = $order_by; return 1; } @@ -572,4 +613,3 @@ Licensed under the Affero GPL. __PACKAGE__->meta->make_immutable; 1; - diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm index 3d4c6a1ba..4f098dfc3 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -58,6 +58,7 @@ sub index : Path : Args(0) { return; } + $c->forward('/auth/get_csrf_token'); } =head2 default @@ -103,9 +104,25 @@ sub page_error_410_gone : Private { sub page_error_403_access_denied : Private { my ( $self, $c, $error_msg ) = @_; + $c->detach('page_error', [ $error_msg || _("Sorry, you don't have permission to do that."), 403 ]); +} + +sub page_error_400_bad_request : Private { + my ( $self, $c, $error_msg ) = @_; + $c->forward('/auth/get_csrf_token'); + $c->detach('page_error', [ $error_msg, 400 ]); +} + +sub page_error_500_internal_error : Private { + my ( $self, $c, $error_msg ) = @_; + $c->detach('page_error', [ $error_msg, 500 ]); +} + +sub page_error : Private { + my ($self, $c, $error_msg, $code) = @_; $c->stash->{template} = 'errors/generic.html'; - $c->stash->{message} = $error_msg || _("Sorry, you don't have permission to do that."); - $c->response->status(403); + $c->stash->{message} = $error_msg || _('Unknown error'); + $c->response->status($code); } =head2 end diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm index da017c57f..a1b0c57ba 100644 --- a/perllib/FixMyStreet/App/Controller/Tokens.pm +++ b/perllib/FixMyStreet/App/Controller/Tokens.pm @@ -348,6 +348,7 @@ sub token_too_old : Private { my ( $self, $c ) = @_; $c->stash->{token_not_found} = 1; $c->stash->{template} = 'auth/token.html'; + $c->response->status(400); } __PACKAGE__->meta->make_immutable; diff --git a/perllib/FixMyStreet/App/Model/EmailSend.pm b/perllib/FixMyStreet/App/Model/EmailSend.pm deleted file mode 100644 index 93751d4a6..000000000 --- a/perllib/FixMyStreet/App/Model/EmailSend.pm +++ /dev/null @@ -1,19 +0,0 @@ -package FixMyStreet::App::Model::EmailSend; -use base 'Catalyst::Model::Factory'; - -use strict; -use warnings; - -=head1 NAME - -FixMyStreet::App::Model::EmailSend - -=head1 DESCRIPTION - -Catalyst Model wrapper around FixMyStreet::EmailSend - -=cut - -__PACKAGE__->config( - class => 'FixMyStreet::EmailSend', -); diff --git a/perllib/FixMyStreet/App/Model/PhotoSet.pm b/perllib/FixMyStreet/App/Model/PhotoSet.pm index 46e1fb630..8fcc1700e 100644 --- a/perllib/FixMyStreet/App/Model/PhotoSet.pm +++ b/perllib/FixMyStreet/App/Model/PhotoSet.pm @@ -67,14 +67,7 @@ has upload_dir => ( is => 'ro', lazy => 1, default => sub { - my $self = shift; - my $cache_dir = path(FixMyStreet->config('UPLOAD_DIR'))->absolute(FixMyStreet->path_to()); - $cache_dir->mkpath; - unless ( -d $cache_dir && -w $cache_dir ) { - warn "Can't find/write to photo cache directory '$cache_dir'"; - return; - } - $cache_dir; + path(FixMyStreet->config('UPLOAD_DIR'))->absolute(FixMyStreet->path_to()); }, ); @@ -191,12 +184,7 @@ has ids => ( # Arrayref of $fileid tuples (always, so post upload/raw data proc $type ||= 'jpeg'; if ($fileid && length($fileid) == 40) { my $file = $self->get_file($fileid, $type); - if ($file->exists) { - $file->basename; - } else { - warn "File $part doesn't exist"; - (); - } + $file->basename; } else { # A bad hash, probably a bot spamming with bad data. (); diff --git a/perllib/FixMyStreet/App/Response.pm b/perllib/FixMyStreet/App/Response.pm new file mode 100644 index 000000000..16ebf995f --- /dev/null +++ b/perllib/FixMyStreet/App/Response.pm @@ -0,0 +1,27 @@ +# This package exists to try and work around a big bug in Edge: +# https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/8572187/ + +package FixMyStreet::App::Response; +use Moose; +extends 'Catalyst::Response'; + +around 'redirect' => sub { + my $orig = shift; + my $self = shift; + my ($location, $status) = @_; + + return $self->$orig() unless @_; # getter + + my $agent = $self->_context->request->user_agent; + return $self->$orig(@_) unless $agent =~ /Edge\/14/; # Only care about Edge + + # Instead of a redirect, output HTML that redirects + $self->body(<<END +<meta http-equiv="refresh" content="0; url=$location"> +Please follow this link: <a href="$location">$location</a> +END + ); + return $location; +}; + +1; diff --git a/perllib/FixMyStreet/App/View/Web.pm b/perllib/FixMyStreet/App/View/Web.pm index f0bcad0be..496463700 100644 --- a/perllib/FixMyStreet/App/View/Web.pm +++ b/perllib/FixMyStreet/App/View/Web.pm @@ -140,21 +140,22 @@ sub escape_js { my %version_hash; sub version { - my ( $self, $c, $file ) = @_; + my ( $self, $c, $file, $url ) = @_; + $url ||= $file; _version_get_mtime($file); if ($version_hash{$file} && $file =~ /\.js$/) { # See if there's an auto.min.js version and use that instead if there is (my $file_min = $file) =~ s/\.js$/.auto.min.js/; _version_get_mtime($file_min); - $file = $file_min if $version_hash{$file_min} >= $version_hash{$file}; + $url = $file = $file_min if $version_hash{$file_min} >= $version_hash{$file}; } my $admin = $self->template->context->stash->{admin} ? FixMyStreet->config('ADMIN_BASE_URL') : ''; - return "$admin$file?$version_hash{$file}"; + return "$admin$url?$version_hash{$file}"; } sub _version_get_mtime { my $file = shift; - unless ($version_hash{$file} && !FixMyStreet->config('STAGING_SITE')) { + unless (defined $version_hash{$file} && !FixMyStreet->config('STAGING_SITE')) { my $path = FixMyStreet->path_to('web', $file); $version_hash{$file} = ( stat( $path ) )[9] || 0; } diff --git a/perllib/FixMyStreet/Cobrand.pm b/perllib/FixMyStreet/Cobrand.pm index 9f61635d8..4b9f2bd0b 100644 --- a/perllib/FixMyStreet/Cobrand.pm +++ b/perllib/FixMyStreet/Cobrand.pm @@ -153,4 +153,14 @@ sub exists { return 0; } +sub body_handler { + my ($class, $areas) = @_; + + foreach my $avail ( $class->available_cobrand_classes ) { + my $cobrand = $class->get_class_for_moniker($avail->{moniker})->new({}); + next unless $cobrand->can('council_id'); + return $cobrand if $areas->{$cobrand->council_id}; + } +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Base.pm b/perllib/FixMyStreet/Cobrand/Base.pm index 5a9842233..ea2b8f410 100644 --- a/perllib/FixMyStreet/Cobrand/Base.pm +++ b/perllib/FixMyStreet/Cobrand/Base.pm @@ -38,6 +38,20 @@ sub moniker { return $last_part; } +=head2 asset_moniker + + $moniker = $cobrand_class->asset_moniker(); + +Same as moniker, except for the cobrand with the 'fixmystreet' moniker, when it +returns 'fixmystreet.com', as to avoid confusion that's where its assets are. + +=cut + +sub asset_moniker { + my $self = shift; + return $self->moniker eq 'fixmystreet' ? 'fixmystreet.com' : $self->moniker; +} + =head2 is_default $bool = $cobrand->is_default(); @@ -51,6 +65,18 @@ sub is_default { return $self->moniker eq 'default'; } +=head2 call_hook + + $cobrand->call_hook(foo => 1, 2, 3); # calls $cobrand->foo(1, 2, 3) if it exists + +=cut + +sub call_hook { + my ($self, $method_name, @args) = @_; + my $method = $self->can($method_name) or return; + return $self->$method(@args); +} + # NB: this Base class is for 'meta' features. To add base methods for all cobrands, # you may want to look at FMS::Cobrand::Default instead! diff --git a/perllib/FixMyStreet/Cobrand/Bristol.pm b/perllib/FixMyStreet/Cobrand/Bristol.pm index ecb19b867..fa7f98666 100644 --- a/perllib/FixMyStreet/Cobrand/Bristol.pm +++ b/perllib/FixMyStreet/Cobrand/Bristol.pm @@ -40,6 +40,10 @@ sub disambiguate_location { }; } +sub get_geocoder { + return 'OSM'; # use OSM geocoder +} + sub pin_colour { my ( $self, $p, $context ) = @_; return 'grey' if $p->state eq 'not responsible'; diff --git a/perllib/FixMyStreet/Cobrand/Bromley.pm b/perllib/FixMyStreet/Cobrand/Bromley.pm index 2d0cb86f1..169175947 100644 --- a/perllib/FixMyStreet/Cobrand/Bromley.pm +++ b/perllib/FixMyStreet/Cobrand/Bromley.pm @@ -3,6 +3,7 @@ use parent 'FixMyStreet::Cobrand::UKCouncils'; use strict; use warnings; +use DateTime::Format::W3CDTF; sub council_id { return 2482; } sub council_area { return 'Bromley'; } @@ -111,5 +112,37 @@ sub title_list { return ["MR", "MISS", "MRS", "MS", "DR"]; } +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $extra = $row->get_extra_fields; + push @$extra, + { name => 'report_url', + value => $h->{url} }, + { name => 'report_title', + value => $row->title }, + { name => 'public_anonymity_required', + value => $row->anonymous ? 'TRUE' : 'FALSE' }, + { name => 'email_alerts_requested', + value => 'FALSE' }, # always false as can never request them + { name => 'requested_datetime', + value => DateTime::Format::W3CDTF->format_datetime($row->confirmed->set_nanosecond(0)) }, + { name => 'email', + value => $row->user->email }; + + # make sure we have last_name attribute present in row's extra, so + # it is passed correctly to Bromley as attribute[] + if ( $row->cobrand ne 'bromley' ) { + my ( $firstname, $lastname ) = ( $row->name =~ /(\w+)\.?\s+(.+)/ ); + push @$extra, { name => 'last_name', value => $lastname }; + } + + $row->set_extra_fields(@$extra); + + $params->{always_send_latlong} = 0; + $params->{send_notpinpointed} = 1; + $params->{extended_description} = 0; +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index 27111deb2..ac70fff08 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -209,14 +209,14 @@ 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). Report may be an object, or a -hashref. +most of the time will be same as base_url_with_lang). Report may be an object, +or a hashref. =cut sub base_url_for_report { my ( $self, $report ) = @_; - return $self->base_url; + return $self->base_url_with_lang; } =head2 base_host @@ -646,27 +646,26 @@ sub admin_pages { $pages->{config} = [ _('Configuration'), 9]; }; # And some that need special permissions - if ( $user->is_superuser || $user->has_body_permission_to('category_edit') ) { + if ( $user->has_body_permission_to('category_edit') ) { my $page_title = $user->is_superuser ? _('Bodies') : _('Categories'); $pages->{bodies} = [ $page_title, 1 ]; $pages->{body} = [ undef, undef ]; } - if ( $user->is_superuser || $user->has_body_permission_to('report_edit') ) { + if ( $user->has_body_permission_to('report_edit') ) { $pages->{reports} = [ _('Reports'), 2 ]; $pages->{report_edit} = [ undef, undef ]; $pages->{update_edit} = [ undef, undef ]; $pages->{abuse_edit} = [ undef, undef ]; } - if ( $user->is_superuser || $user->has_body_permission_to('template_edit') ) { + if ( $user->has_body_permission_to('template_edit') ) { $pages->{templates} = [ _('Templates'), 3 ]; $pages->{template_edit} = [ undef, undef ]; }; - if ( $user->is_superuser || $user->has_body_permission_to('responsepriority_edit') ) { + if ( $user->has_body_permission_to('responsepriority_edit') ) { $pages->{responsepriorities} = [ _('Priorities'), 4 ]; $pages->{responsepriority_edit} = [ undef, undef ]; }; - - if ( $user->is_superuser || $user->has_body_permission_to('user_edit') ) { + if ( $user->has_body_permission_to('user_edit') ) { $pages->{users} = [ _('Users'), 6 ]; $pages->{user_edit} = [ undef, undef ]; } @@ -713,6 +712,7 @@ sub available_permissions { planned_reports => _("Manage shortlist"), contribute_as_another_user => _("Create reports/updates on a user's behalf"), contribute_as_body => _("Create reports/updates as the council"), + view_body_contribute_details => _("See user detail for reports created as the council"), # NB this permission is special in that it can be assigned to users # without their from_body being set. It's included here for @@ -873,13 +873,11 @@ sub get_body_sender { # look up via category my $contact = $body->contacts->search( { category => $category } )->first; - if ( $body->can_be_devolved ) { - if ( $contact->send_method ) { - return { method => $contact->send_method, config => $contact, contact => $contact }; - } else { - return { method => $body->send_method, config => $body, contact => $contact }; - } - } elsif ( $body->send_method ) { + if ( $body->can_be_devolved && $contact->send_method ) { + return { method => $contact->send_method, config => $contact, contact => $contact }; + } + + if ( $body->send_method ) { return { method => $body->send_method, config => $body, contact => $contact }; } @@ -1187,4 +1185,32 @@ sub category_extra_hidden { return 0; } +=head2 reputation_increment_states/reputation_decrement_states + +Get a hashref of states that cause the reporting user's reputation to be +incremented/decremented, if a report is changed to this state upon inspection. + +=cut + +sub reputation_increment_states { {} }; +sub reputation_decrement_states { {} }; + +sub traffic_management_options { + return [ + _("Yes"), + _("No"), + ]; +} + + +=head2 display_days_ago_threshold + +Used to control whether a relative 'n days ago' or absolute date is shown +for problems/updates. If a problem/update's `days_ago` value is <= this figure, +the 'n days ago' format is used. By default the absolute date is always used. + +=cut +sub display_days_ago_threshold { 0 } + + 1; diff --git a/perllib/FixMyStreet/Cobrand/FiksGataMi.pm b/perllib/FixMyStreet/Cobrand/FiksGataMi.pm index 5e2473280..0f8516afc 100644 --- a/perllib/FixMyStreet/Cobrand/FiksGataMi.pm +++ b/perllib/FixMyStreet/Cobrand/FiksGataMi.pm @@ -37,7 +37,7 @@ sub pin_colour { sub area_types { my $self = shift; - return $self->next::method() if FixMyStreet->config('STAGING_SITE') && FixMyStreet->config('SKIP_CHECKS_ON_STAGING'); + return $self->next::method() if FixMyStreet->staging_flag('skip_checks'); [ 'NKO', 'NFY', 'NRA' ]; } diff --git a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm index 1fb822893..1052bac0e 100644 --- a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm +++ b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm @@ -60,26 +60,5 @@ sub extra_contact_validation { return %errors; } -sub report_form_extras { - ( { name => 'gender', required => 0 }, { name => 'variant', required => 0 } ) -} - -sub ask_gender_question { - my $self = shift; - - return 1 unless $self->{c}->user; - - my $reports = $self->{c}->model('DB::Problem')->search({ - user_id => $self->{c}->user->id, - extra => { like => '%gender%' } - }, { order_by => { -desc => 'id' } }); - - while (my $report = $reports->next) { - my $gender = $report->get_extra_metadata('gender'); - return 0 if $gender =~ /female|male|other|unknown/; - } - return 1; -} - 1; diff --git a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm index 5b78b3fa1..324811008 100644 --- a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm +++ b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm @@ -31,7 +31,7 @@ sub disambiguate_location { sub area_types { my $self = shift; - return $self->next::method() if FixMyStreet->config('STAGING_SITE') && FixMyStreet->config('SKIP_CHECKS_ON_STAGING'); + return $self->next::method() if FixMyStreet->staging_flag('skip_checks'); [ 'KOM' ]; } diff --git a/perllib/FixMyStreet/Cobrand/Greenwich.pm b/perllib/FixMyStreet/Cobrand/Greenwich.pm index 7777079a9..700a12782 100644 --- a/perllib/FixMyStreet/Cobrand/Greenwich.pm +++ b/perllib/FixMyStreet/Cobrand/Greenwich.pm @@ -55,4 +55,19 @@ sub contact_email { return join( '@', 'fixmystreet', 'royalgreenwich.gov.uk' ); } +sub reports_per_page { return 20; } + +sub on_map_default_max_pin_age { + return '21 days'; +} + +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $extra = $row->get_extra_fields; + # Greenwich doesn't have category metadata to fill this + push @$extra, { name => 'external_id', value => $row->id }; + $row->set_extra_fields( @$extra ); +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/Harrogate.pm b/perllib/FixMyStreet/Cobrand/Harrogate.pm deleted file mode 100644 index 8f4a6e2ea..000000000 --- a/perllib/FixMyStreet/Cobrand/Harrogate.pm +++ /dev/null @@ -1,290 +0,0 @@ -package FixMyStreet::Cobrand::Harrogate; -use base 'FixMyStreet::Cobrand::UKCouncils'; - -use strict; -use warnings; -use feature 'say'; - -sub council_id { return 2407; } -sub council_area { return 'Harrogate'; } -sub council_name { return 'Harrogate Borough Council'; } -sub council_url { return 'harrogate'; } -sub is_two_tier { return 1; } # with North Yorkshire CC 2235 - -sub base_url { - my $self = shift; - return $self->next::method() if FixMyStreet->config('STAGING_SITE'); - return 'http://fix.harrogate.gov.uk'; -} - -sub disambiguate_location { - my $self = shift; - my $string = shift; - - my $town = 'Harrogate'; - - # as it's the requested example location, try to avoid a disambiguation page - $town .= ', HG1 1DH' if $string =~ /^\s*king'?s\s+r(?:oa)?d\s*(?:,\s*har\w+\s*)?$/i; - - return { - %{ $self->SUPER::disambiguate_location() }, - town => $town, - centre => '54.0671557690306,-1.59581319536637', - span => '0.370193897090822,0.829517054931808', - bounds => [ 53.8914112467619, -2.00450542308575, 54.2616051438527, -1.17498836815394 ], - }; -} - -sub example_places { - return ( 'HG1 2SG', "King's Road" ); -} - -sub enter_postcode_text { - my ($self) = @_; - return 'Enter a Harrogate district postcode, or street name and area'; -} - -# increase map zoom level so street names are visible -sub default_map_zoom { return 3; } - - -=head2 temp_email_to_update, temp_update_contacts - -Temporary helper routines to update the extra for potholes (temporary setup -hack, cargo-culted from ESCC, may in future be superseded either by -Open311/integration or a better mechanism for manually creating rich contacts). - -Can run with a script or command line like: - - bin/cron-wrapper perl -MFixMyStreet::App -MFixMyStreet::Cobrand::Harrogate -e \ - 'FixMyStreet::Cobrand::Harrogate->new({c => FixMyStreet::App->new})->temp_update_contacts' - -=cut - -sub temp_email_to_update { - return 'CustomerServices@harrogate.gov.uk'; -} - -sub temp_update_contacts { - my $self = shift; - - my $contact_rs = $self->{c}->model('DB::Contact'); - - my $email = $self->temp_email_to_update; - my $_update = sub { - my ($category, $field, $category_details) = @_; - # NB: we're accepting just 1 field, but supply as array [ $field ] - - my $contact = $contact_rs->find_or_create( - { - body_id => $self->council_id, - category => $category, - - confirmed => 1, - deleted => 0, - email => $email, - editor => 'automated script', - note => '', - send_method => '', - whenedited => \'NOW()', - %{ $category_details || {} }, - }, - { - key => 'contacts_body_id_category_idx' - } - ); - - say "Editing category: $category"; - - my %default = ( - variable => 'true', - order => '1', - required => 'no', - datatype => 'string', - datatype_description => 'a string', - ); - - if ($field->{datatype} || '' eq 'boolean') { - my $description = $field->{description}; - %default = ( - %default, - datatype => 'singlevaluelist', - datatype_description => 'Yes or No', - values => { value => [ - { key => ['No'], name => ['No'] }, - { key => ['Yes'], name => ['Yes'] }, - ] }, - ); - } - - $contact->update({ - # XXX: we're just setting extra with the expected layout, - # this could be encapsulated more nicely - extra => { _fields => [ { %default, %$field } ] }, - confirmed => 1, - deleted => 0, - editor => 'automated script', - whenedited => \'NOW()', - note => 'Edited by script as per requirements Dec 2014', - }); - }; - - $_update->( 'Abandoned vehicles', { - code => 'registration', - description => 'Vehicle Registration number:', - }); - - $_update->( 'Dead animals', { - code => 'INFO_TEXT', - variable => 'false', - description => 'We do not remove small species, e.g. squirrels, rabbits, and small birds.', - }); - - $_update->( 'Flyposting', { - code => 'offensive', - description => 'Is it offensive?', - datatype => 'boolean', # mapped onto singlevaluelist - }); - - $_update->( 'Flytipping', { - code => 'size', - description => 'Size?', - datatype => 'singlevaluelist', - values => { value => [ - { key => ['Single Item'], name => ['Single item'] }, - { key => ['Car boot load'], name => ['Car boot load'] }, - { key => ['Small van load'], name => ['Small van load'] }, - { key => ['Transit van load'], name => ['Transit van load'] }, - { key => ['Tipper lorry load'], name => ['Tipper lorry load'] }, - { key => ['Significant load'], name => ['Significant load'] }, - ] }, - }); - - $_update->( 'Graffiti', { - code => 'offensive', - description => 'Is it offensive?', - datatype => 'boolean', # mapped onto singlevaluelist - }); - - $_update->( 'Parks and playgrounds', { - code => 'dangerous', - description => 'Is it dangerous or could cause injury?', - datatype => 'boolean', # mapped onto singlevaluelist - }); - - $_update->( 'Trees', { - code => 'dangerous', - description => 'Is it dangerous or could cause injury?', - datatype => 'boolean', # mapped onto singlevaluelist - }); - - # also ensure that the following categories are created: - for my $category ( - 'Car parking', - 'Dog and litter bins', - 'Dog fouling', - 'Other', - 'Rubbish (refuse and recycling)', - 'Street cleaning', - 'Street lighting', - 'Street nameplates', - ) { - say "Creating $category if required"; - my $contact = $contact_rs->find_or_create( - { - body_id => $self->council_id, - category => $category, - confirmed => 1, - deleted => 0, - email => $email, - editor => 'automated script', - note => 'Created by script as per requirements Dec 2014', - send_method => '', - whenedited => \'NOW()', - } - ); - } - - my @to_delete = ( - 'Parks/landscapes', # delete in favour of to parks and playgrounds - 'Public toilets', # as no longer in specs - ); - say sprintf "Deleting: %s (if present)", join ',' => @to_delete; - $contact_rs->search({ - body_id => $self->council_id, - category => \@to_delete, - deleted => 0 - })->update({ - deleted => 1, - editor => 'automated script', - whenedited => \'NOW()', - note => 'Deleted by script as per requirements Dec 2014', - }); -} - -sub contact_email { - my $self = shift; - return join( '@', 'customerservices', 'harrogate.gov.uk' ); -} - -sub process_additional_metadata_for_email { - my ($self, $problem, $h) = @_; - - my $additional = ''; - if (my $extra = $problem->get_extra_fields) { - $additional = join "\n\n", map { - if ($_->{name} eq 'INFO_TEXT') { - (); - } - else { - sprintf '%s: %s', $_->{description}, $_->{value}; - } - } @$extra; - $additional = "\n\n$additional" if $additional; - } - - $h->{additional_information} = $additional; -} - -sub send_questionnaires { - return 0; -} - -sub munge_category_list { - my ($self, $categories_ref, $contacts_ref, $extras_ref) = @_; - - # we want to know which contacts *only* belong to NYCC - # that's because for shared responsibility, we don't expect - # the user to have to figure out which authority to contact. - - # so we start building up the list of both - my (%harrogate_contacts, %nycc_contacts); - - my $harrogate_id = $self->council_id; # XXX: note reference to council_id as body id! - for my $contact (@$contacts_ref) { - my $category = $contact->category; - if ($contact->body_id == $harrogate_id) { - $harrogate_contacts{$category} = 1; - } - else { - $nycc_contacts{$category}++; - } - } - - # and then remove any that also have Harrogate involvement - delete $nycc_contacts{$_} for keys %harrogate_contacts; - - # here, we simply *mark* the text with (NYCC) at the end, and - # the rest will get done in the template with javascript - my @categories = map { - $nycc_contacts{$_} ? - "$_ (NYCC)" - : $_ - } @$categories_ref; - - # replace the entire list with this transformed one - @$categories_ref = @categories; -} - -1; - diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm index dca208e98..3e262a700 100644 --- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm +++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm @@ -112,6 +112,29 @@ sub pin_colour { return 'yellow'; } +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $extra = $row->get_extra_fields; + push @$extra, { name => 'external_id', value => $row->id }; + + if ($h->{closest_address}) { + push @$extra, { name => 'closest_address', value => $h->{closest_address} } + } + if ( $row->used_map || ( !$row->used_map && !$row->postcode ) ) { + push @$extra, { name => 'northing', value => $h->{northing} }; + push @$extra, { name => 'easting', value => $h->{easting} }; + } + $row->set_extra_fields( @$extra ); + + $params->{extended_description} = 'oxfordshire'; +} + +sub open311_pre_send { + my ($self, $row, $open311) = @_; + $open311->endpoints( { requests => 'open311_service_request.cgi' } ); +} + sub on_map_default_status { return 'open'; } sub contact_email { @@ -121,4 +144,70 @@ sub contact_email { sub admin_user_domain { 'oxfordshire.gov.uk' } +sub traffic_management_options { + return [ + "Signs and Cones", + "Stop and Go Boards", + "High Speed Roads", + ]; +} + +sub admin_pages { + my $self = shift; + + my $user = $self->{c}->user; + + my $pages = $self->next::method(); + + # Oxfordshire have a custom admin page for downloading reports in an Exor- + # friendly format which anyone with report_instruct permission can use. + if ( $user->has_body_permission_to('report_instruct') ) { + $pages->{exordefects} = [ ('Download Exor RDI'), 10 ]; + } + if ( $user->has_body_permission_to('defect_type_edit') ) { + $pages->{defecttypes} = [ ('Defect Types'), 11 ]; + $pages->{defecttype_edit} = [ undef, undef ]; + }; + + return $pages; +} + +sub defect_types { + { + SFP2 => "SFP2: sweep and fill <1m2", + POT2 => "POT2", + }; +} + +sub exor_rdi_link_id { 1989169 } +sub exor_rdi_link_length { 50 } + +sub reputation_increment_states { + return { + 'action scheduled' => 1, + }; +} + +sub user_extra_fields { + return [ 'initials' ]; +} + +sub display_days_ago_threshold { 28 } + +sub defect_type_extra_fields { + return [ + 'activity_code', + 'defect_code', + ]; +}; + +sub available_permissions { + my $self = shift; + + my $perms = $self->next::method(); + $perms->{Bodies}->{defect_type_edit} = "Add/edit defect types"; + + return $perms; +} + 1; diff --git a/perllib/FixMyStreet/Cobrand/SeeSomething.pm b/perllib/FixMyStreet/Cobrand/SeeSomething.pm deleted file mode 100644 index 4d4dd000e..000000000 --- a/perllib/FixMyStreet/Cobrand/SeeSomething.pm +++ /dev/null @@ -1,135 +0,0 @@ -package FixMyStreet::Cobrand::SeeSomething; -use parent 'FixMyStreet::Cobrand::UKCouncils'; - -use strict; -use warnings; - -sub council_id { return [ 2520, 2522, 2514, 2546, 2519, 2538, 2535 ]; } -sub council_area { return 'West Midlands'; } -sub council_name { return 'See Something Say Something'; } -sub council_url { return 'seesomething'; } -sub area_types { [ 'MTD' ] } - -sub area_check { - my ( $self, $params, $context ) = @_; - - my $councils = $params->{all_areas}; - my $council_match = grep { $councils->{$_} } @{ $self->council_id }; - - if ($council_match) { - return 1; - } - - return ( 0, "That location is not covered by See Something, Say Something" ); -} - -sub disambiguate_location { - my $self = shift; - my $string = shift; - - my $town = 'West Midlands'; - - return { - %{ $self->SUPER::disambiguate_location() }, - town => $town, - centre => '52.4803101685267,-2.2708272758854', - span => '1.4002794815887,2.06340043925997', - bounds => [ 51.8259444771676, -3.23554082684068, 53.2262239587563, -1.17214038758071 ], - }; -} - -sub example_places { - return ( 'WS1 4NH', 'Austin Drive, Coventry' ); -} - -sub send_questionnaires { - return 0; -} - -sub ask_ever_reported { - return 0; -} - -sub report_sent_confirmation_email { 1; } - -sub report_check_for_errors { return (); } - -sub never_confirm_reports { 1; } - -sub allow_anonymous_reports { 1; } - -sub anonymous_account { return { name => 'Anonymous Submission', email => FixMyStreet->config('DO_NOT_REPLY_EMAIL') }; } - -sub admin_allow_user { - my ( $self, $user ) = @_; - return 1 if ( $user->from_body || $user->is_superuser ); -} - -sub admin_pages { - my $self = shift; - - return { - 'stats' => ['Reports', 0], - }; -}; - -sub admin_stats { - my $self = shift; - my $c = $self->{c}; - - my %filters = (); - - # XXX The below lookup assumes a body ID === MapIt area ID - my %councils = - map { - my $name = $_->name; - $name =~ s/(?:Borough|City) Council//; - ($_->id => $name); - } - $c->model('DB::Body')->search({ id => $self->council_id }); - - $c->stash->{council_details} = \%councils; - - if ( !$c->user_exists || !grep { $_ == $c->user->from_body->id } @{ $self->council_id } ) { - $c->detach( '/page_error_404_not_found' ); - } - - if ( $c->get_param('category') ) { - $filters{category} = $c->get_param('category'); - $c->stash->{category} = $c->get_param('category'); - } - - if ( $c->get_param('subcategory') ) { - $filters{subcategory} = $c->get_param('subcategory'); - $c->stash->{subcategory} = $c->get_param('subcategory'); - } - - if ( $c->get_param('service') ) { - $filters{service} = { -ilike => $c->get_param('service') }; - $c->stash->{service} = $c->get_param('service'); - } - - my $page = $c->get_param('p') || 1; - - my $p = $c->model('DB::Problem')->search( - { - confirmed => { not => undef }, - %filters - }, - { - columns => [ qw( - service category subcategory confirmed bodies_str - ) ], - order_by => { -desc=> [ 'confirmed' ] }, - rows => 20, - } - )->page( $page ); - - $c->stash->{reports} = $p; - $c->stash->{pager} = $p->pager; - - return 1; -} - -1; - diff --git a/perllib/FixMyStreet/Cobrand/UK.pm b/perllib/FixMyStreet/Cobrand/UK.pm index 08ecf0b7d..945af48f8 100644 --- a/perllib/FixMyStreet/Cobrand/UK.pm +++ b/perllib/FixMyStreet/Cobrand/UK.pm @@ -1,5 +1,6 @@ package FixMyStreet::Cobrand::UK; use base 'FixMyStreet::Cobrand::Default'; +use strict; use JSON::MaybeXS; use mySociety::MaPit; @@ -354,13 +355,8 @@ sub get_body_handler_for_problem { my @bodies = values %{$row->bodies}; my %areas = map { %{$_->areas} } @bodies; - foreach my $avail ( FixMyStreet::Cobrand->available_cobrand_classes ) { - my $class = FixMyStreet::Cobrand->get_class_for_moniker($avail->{moniker}); - my $cobrand = $class->new({}); - next unless $cobrand->can('council_id'); - return $cobrand if $areas{$cobrand->council_id}; - } - + my $cobrand = FixMyStreet::Cobrand->body_handler(\%areas); + return $cobrand if $cobrand; return ref $self ? $self : $self->new; } diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index c22224307..e0b6b5298 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -42,13 +42,13 @@ sub restriction { sub problems_restriction { my ($self, $rs) = @_; - return $rs if FixMyStreet->config('STAGING_SITE') && FixMyStreet->config('SKIP_CHECKS_ON_STAGING'); + return $rs if FixMyStreet->staging_flag('skip_checks'); return $rs->to_body($self->council_id); } sub updates_restriction { my ($self, $rs) = @_; - return $rs if FixMyStreet->config('STAGING_SITE') && FixMyStreet->config('SKIP_CHECKS_ON_STAGING'); + return $rs if FixMyStreet->staging_flag('skip_checks'); return $rs->to_body($self->council_id); } @@ -76,7 +76,7 @@ sub users_restriction { my $or_query = [ from_body => $self->council_id, - id => [ { -in => $problem_user_ids }, { -in => $update_user_ids } ], + 'me.id' => [ { -in => $problem_user_ids }, { -in => $update_user_ids } ], ]; if ($self->can('admin_user_domain')) { my $domain = $self->admin_user_domain; @@ -105,7 +105,7 @@ sub enter_postcode_text { sub area_check { my ( $self, $params, $context ) = @_; - return 1 if FixMyStreet->config('STAGING_SITE') && FixMyStreet->config('SKIP_CHECKS_ON_STAGING'); + return 1 if FixMyStreet->staging_flag('skip_checks'); my $councils = $params->{all_areas}; my $council_match = defined $councils->{$self->council_id}; @@ -200,6 +200,7 @@ sub available_permissions { my $perms = $self->next::method(); $perms->{Problems}->{contribute_as_body} = "Create reports/updates as " . $self->council_name; + $perms->{Problems}->{view_body_contribute_details} = "See user detail for reports created as " . $self->council_name; $perms->{Users}->{user_assign_areas} = "Assign users to areas in " . $self->council_name; return $perms; diff --git a/perllib/FixMyStreet/Cobrand/WestBerkshire.pm b/perllib/FixMyStreet/Cobrand/WestBerkshire.pm new file mode 100644 index 000000000..7e98187bb --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/WestBerkshire.pm @@ -0,0 +1,16 @@ +package FixMyStreet::Cobrand::WestBerkshire; +use base 'FixMyStreet::Cobrand::UK'; + +use strict; +use warnings; + +sub council_id { 2619 } + +# non standard west berks end points +sub open311_pre_send { + my ($self, $row, $open311) = @_; + $open311->endpoints( { services => 'Services', requests => 'Requests' } ); +} + +1; + diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm index 037b69352..82015ad2d 100644 --- a/perllib/FixMyStreet/DB/Result/Body.pm +++ b/perllib/FixMyStreet/DB/Result/Body.pm @@ -75,6 +75,12 @@ __PACKAGE__->has_many( { "foreign.body_id" => "self.id" }, { cascade_copy => 0, cascade_delete => 0 }, ); +__PACKAGE__->has_many( + "defect_types", + "FixMyStreet::DB::Result::DefectType", + { "foreign.body_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); __PACKAGE__->belongs_to( "parent", "FixMyStreet::DB::Result::Body", @@ -112,8 +118,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-09-06 15:33:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZuzscnLqcx0k512cTZ/kdg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BOJANVwg3kR/1VjDq0LykA sub url { my ( $self, $c, $args ) = @_; @@ -127,4 +133,19 @@ sub areas { return \%ids; } +=head2 get_cobrand_handler + +Get a cobrand object for this body, if there is one. + +e.g. + * if the problem was sent to Bromley it will return ::Bromley + * if the problem was sent to Camden it will return nothing + +=cut + +sub get_cobrand_handler { + my $self = shift; + return FixMyStreet::Cobrand->body_handler($self->areas); +} + 1; diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm index f5601639a..cf1ba444d 100644 --- a/perllib/FixMyStreet/DB/Result/Comment.pm +++ b/perllib/FixMyStreet/DB/Result/Comment.pm @@ -6,6 +6,7 @@ package FixMyStreet::DB::Result::Comment; use strict; use warnings; +use FixMyStreet::Template; use base 'DBIx::Class::Core'; __PACKAGE__->load_components("FilterColumn", "InflateColumn::DateTime", "EncodedColumn"); @@ -199,7 +200,7 @@ __PACKAGE__->has_many( "admin_log_entries", "FixMyStreet::DB::Result::AdminLog", { "foreign.object_id" => "self.id" }, - { + { cascade_copy => 0, cascade_delete => 0, where => { 'object_type' => 'update' }, } @@ -223,4 +224,92 @@ __PACKAGE__->might_have( { cascade_copy => 0, cascade_delete => 1 }, ); +=head2 meta_line + +Returns a string to be used on a report update, describing some of the metadata +about an update + +=cut + +sub meta_line { + my ( $self, $c ) = @_; + + my $meta = ''; + + $c->stash->{last_state} ||= ''; + + if ($self->anonymous or !$self->name) { + $meta = sprintf( _( 'Posted anonymously at %s' ), Utils::prettify_dt( $self->confirmed ) ) + } elsif ($self->user->from_body) { + my $user_name = FixMyStreet::Template::html_filter($self->user->name); + my $body = $self->user->body; + if ($body eq 'Bromley Council') { + $body = "$body <img src='/cobrands/bromley/favicon.png' alt=''>"; + } elsif ($body eq 'Royal Borough of Greenwich') { + $body = "$body <img src='/cobrands/greenwich/favicon.png' alt=''>"; + } + if ($c->user_exists and $c->user->has_permission_to('view_body_contribute_details', $self->problem->bodies_str_ids)) { + $meta = sprintf( _( 'Posted by <strong>%s</strong> (%s) at %s' ), $body, $user_name, Utils::prettify_dt( $self->confirmed ) ); + } else { + $meta = sprintf( _( 'Posted by <strong>%s</strong> at %s' ), $body, Utils::prettify_dt( $self->confirmed ) ); + } + } else { + $meta = sprintf( _( 'Posted by %s at %s' ), FixMyStreet::Template::html_filter($self->name), Utils::prettify_dt( $self->confirmed ) ) + } + + my $update_state = ''; + + if ($self->mark_fixed) { + $update_state = _( 'marked as fixed' ); + } elsif ($self->mark_open) { + $update_state = _( 'reopened' ); + } elsif ($self->problem_state) { + my $state = $self->problem_state_display; + + if ($state eq 'confirmed') { + if ($c->stash->{last_state}) { + $update_state = _( 'reopened' ) + } + } elsif ($state eq 'investigating') { + $update_state = _( 'marked as investigating' ) + } elsif ($state eq 'planned') { + $update_state = _( 'marked as planned' ) + } elsif ($state eq 'in progress') { + $update_state = _( 'marked as in progress' ) + } elsif ($state eq 'action scheduled') { + $update_state = _( 'marked as action scheduled' ) + } elsif ($state eq 'closed') { + $update_state = _( 'marked as closed' ) + } elsif ($state eq 'fixed') { + $update_state = _( 'marked as fixed' ) + } elsif ($state eq 'unable to fix') { + $update_state = _( 'marked as no further action' ) + } elsif ($state eq 'not responsible') { + $update_state = _( "marked as not the council's responsibility" ) + } elsif ($state eq 'duplicate') { + $update_state = _( 'closed as a duplicate report' ) + } elsif ($state eq 'internal referral') { + $update_state = _( 'marked as an internal referral' ) + } + + if ($c->cobrand->moniker eq 'bromley' || ( + $self->problem->bodies_str && + $self->problem->bodies_str eq '2482' + )) { + if ($state eq 'not responsible') { + $update_state = 'marked as third party responsibility' + } + } + + } + + if ($update_state ne $c->stash->{last_state} and $update_state) { + $meta .= ", $update_state"; + } + + $c->stash->{last_state} = $update_state; + + return $meta; +}; + 1; diff --git a/perllib/FixMyStreet/DB/Result/Contact.pm b/perllib/FixMyStreet/DB/Result/Contact.pm index 0c9a7c0d8..a620b7358 100644 --- a/perllib/FixMyStreet/DB/Result/Contact.pm +++ b/perllib/FixMyStreet/DB/Result/Contact.pm @@ -56,6 +56,12 @@ __PACKAGE__->belongs_to( { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, ); __PACKAGE__->has_many( + "contact_defect_types", + "FixMyStreet::DB::Result::ContactDefectType", + { "foreign.contact_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); +__PACKAGE__->has_many( "contact_response_priorities", "FixMyStreet::DB::Result::ContactResponsePriority", { "foreign.contact_id" => "self.id" }, @@ -69,8 +75,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-09-06 15:33:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ocmQGeFJtO3wmvyx6W+EKQ +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:f9VepR/oPyr3z6PUpJ4w2A __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); __PACKAGE__->rabx_column('extra'); @@ -82,11 +88,12 @@ with 'FixMyStreet::Roles::Extra'; __PACKAGE__->many_to_many( response_templates => 'contact_response_templates', 'response_template' ); __PACKAGE__->many_to_many( response_priorities => 'contact_response_priorities', 'response_priority' ); +__PACKAGE__->many_to_many( defect_types => 'contact_defect_types', 'defect_type' ); sub get_metadata_for_input { my $self = shift; my $id_field = $self->id_field; - my @metadata = grep { $_->{code} !~ /^(easting|northing|$id_field)$/ } @{$self->get_extra_fields}; + my @metadata = grep { $_->{code} !~ /^(easting|northing|closest_address|$id_field)$/ } @{$self->get_extra_fields}; # Just in case the extra data is in an old parsed format foreach (@metadata) { diff --git a/perllib/FixMyStreet/DB/Result/ContactDefectType.pm b/perllib/FixMyStreet/DB/Result/ContactDefectType.pm new file mode 100644 index 000000000..2199f0b42 --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/ContactDefectType.pm @@ -0,0 +1,46 @@ +use utf8; +package FixMyStreet::DB::Result::ContactDefectType; + +# 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("contact_defect_types"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "contact_defect_types_id_seq", + }, + "contact_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "defect_type_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->belongs_to( + "contact", + "FixMyStreet::DB::Result::Contact", + { id => "contact_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); +__PACKAGE__->belongs_to( + "defect_type", + "FixMyStreet::DB::Result::DefectType", + { id => "defect_type_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:VIczmM0OXXpWgQVpop3SMw + + +# 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/DefectType.pm b/perllib/FixMyStreet/DB/Result/DefectType.pm new file mode 100644 index 000000000..a2969f59e --- /dev/null +++ b/perllib/FixMyStreet/DB/Result/DefectType.pm @@ -0,0 +1,66 @@ +use utf8; +package FixMyStreet::DB::Result::DefectType; + +# 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("defect_types"); +__PACKAGE__->add_columns( + "id", + { + data_type => "integer", + is_auto_increment => 1, + is_nullable => 0, + sequence => "defect_types_id_seq", + }, + "body_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, + "name", + { data_type => "text", is_nullable => 0 }, + "description", + { data_type => "text", is_nullable => 0 }, + "extra", + { data_type => "text", is_nullable => 1 }, +); +__PACKAGE__->set_primary_key("id"); +__PACKAGE__->add_unique_constraint("defect_types_body_id_name_key", ["body_id", "name"]); +__PACKAGE__->belongs_to( + "body", + "FixMyStreet::DB::Result::Body", + { id => "body_id" }, + { is_deferrable => 0, on_delete => "NO ACTION", on_update => "NO ACTION" }, +); +__PACKAGE__->has_many( + "contact_defect_types", + "FixMyStreet::DB::Result::ContactDefectType", + { "foreign.defect_type_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); +__PACKAGE__->has_many( + "problems", + "FixMyStreet::DB::Result::Problem", + { "foreign.defect_type_id" => "self.id" }, + { cascade_copy => 0, cascade_delete => 0 }, +); + + +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BBLjb/aAoTKJZerdYCeBMQ + +__PACKAGE__->many_to_many( contacts => 'contact_defect_types', 'contact' ); + +__PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn"); +__PACKAGE__->rabx_column('extra'); + +use Moo; +use namespace::clean -except => [ 'meta' ]; + +with 'FixMyStreet::Roles::Extra'; + + +1; diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm index 203e72fae..84db41490 100644 --- a/perllib/FixMyStreet/DB/Result/Problem.pm +++ b/perllib/FixMyStreet/DB/Result/Problem.pm @@ -108,6 +108,8 @@ __PACKAGE__->add_columns( { data_type => "text", is_nullable => 1 }, "response_priority_id", { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, + "defect_type_id", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->has_many( @@ -116,6 +118,17 @@ __PACKAGE__->has_many( { "foreign.problem_id" => "self.id" }, { cascade_copy => 0, cascade_delete => 0 }, ); +__PACKAGE__->belongs_to( + "defect_type", + "FixMyStreet::DB::Result::DefectType", + { id => "defect_type_id" }, + { + is_deferrable => 0, + join_type => "LEFT", + on_delete => "NO ACTION", + on_update => "NO ACTION", + }, +); __PACKAGE__->has_many( "moderation_original_datas", "FixMyStreet::DB::Result::ModerationOriginalData", @@ -153,8 +166,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-09-07 11:01:40 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:iH9c4VZZN/ONnhN6g89DFw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2017-02-13 15:11:11 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:8zzWlJX7OQOdvrGxKuZUmg # Add fake relationship to stored procedure table __PACKAGE__->has_one( @@ -182,6 +195,8 @@ use Utils; use FixMyStreet::Map::FMS; use LWP::Simple qw($ua); use RABX; +use URI; +use URI::QueryParam; my $IM = eval { require Image::Magick; @@ -511,6 +526,30 @@ sub admin_url { return $cobrand->admin_base_url . '/report_edit/' . $self->id; } +=head2 tokenised_url + +Return a url for this problem report that logs a user in + +=cut + +sub tokenised_url { + my ($self, $user, $params) = @_; + + my $token = FixMyStreet::App->model('DB::Token')->create( + { + scope => 'email_sign_in', + data => { + id => $self->id, + email => $user->email, + r => $self->url, + p => $params, + } + } + ); + + return "/M/". $token->token; +} + =head2 is_open Returns 1 if the problem is in a open state otherwise 0. @@ -523,6 +562,16 @@ sub is_open { return exists $self->open_states->{ $self->state } ? 1 : 0; } +=head2 is_in_progress + +Sees if the problem is in an open, not 'confirmed' state. + +=cut + +sub is_in_progress { + my $self = shift; + return $self->is_open && $self->state ne 'confirmed' ? 1 : 0; +} =head2 is_fixed @@ -587,9 +636,7 @@ sub meta_line { my $meta = ''; my $category = $problem->category; - if ($c->cobrand->can('change_category_text')) { - $category = $c->cobrand->change_category_text($category); - } + $category = $c->cobrand->call_hook(change_category_text => $category) || $category; if ( $problem->anonymous ) { if ( $problem->service and $category && $category ne _('Other') ) { @@ -606,20 +653,28 @@ sub meta_line { $meta = sprintf( _('Reported anonymously at %s'), $date_time ); } } else { + my $problem_name = $problem->name; + + if ($c->user_exists and + $c->user->has_permission_to('view_body_contribute_details', $problem->bodies_str_ids) and + $problem->name ne $problem->user->name) { + $problem_name = sprintf('%s (%s)', $problem->name, $problem->user->name ); + } + if ( $problem->service and $category && $category ne _('Other') ) { $meta = sprintf( _('Reported via %s in the %s category by %s at %s'), $problem->service, $category, - $problem->name, $date_time + $problem_name, $date_time ); } elsif ( $problem->service ) { $meta = sprintf( _('Reported via %s by %s at %s'), - $problem->service, $problem->name, $date_time ); + $problem->service, $problem_name, $date_time ); } elsif ( $category and $category ne _('Other') ) { $meta = sprintf( _('Reported in the %s category by %s at %s'), - $category, $problem->name, $date_time ); + $category, $problem_name, $date_time ); } else { - $meta = sprintf( _('Reported by %s at %s'), $problem->name, $date_time ); + $meta = sprintf( _('Reported by %s at %s'), $problem_name, $date_time ); } } @@ -651,6 +706,34 @@ sub body { return $body; } + +=head2 time_ago + Returns how long ago a problem was reported in an appropriately + prettified duration, depending on the duration. +=cut + +sub time_ago { + my ( $self, $date ) = @_; + $date ||= 'confirmed'; + my $duration = time() - $self->$date->epoch; + + return Utils::prettify_duration( $duration ); +} + +=head2 days_ago + + Returns how many days ago a problem was reported. + +=cut + +sub days_ago { + my ( $self, $date ) = @_; + $date ||= 'confirmed'; + my $now = DateTime->now( time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone ); + my $duration = $now->delta_days($self->$date); + return $duration->delta_days; +} + =head2 response_templates Returns all ResponseTemplates attached to this problem's bodies, in alphabetical @@ -684,6 +767,18 @@ sub response_priorities { return $self->result_source->schema->resultset('ResponsePriority')->for_bodies($self->bodies_str_ids, $self->category); } +=head2 defect_types + +Returns all DefectTypes attached to this problem's category/contact, in +alphabetical order of name. + +=cut + +sub defect_types { + my $self = shift; + return $self->result_source->schema->resultset('DefectType')->for_bodies($self->bodies_str_ids, $self->category); +} + # returns true if the external id is the council's ref, i.e., useful to publish it # (by way of an example, the Oxfordshire send method returns a useful reference when # it succeeds, so that is the ref we should show on the problem report page). @@ -700,17 +795,10 @@ sub can_display_external_id { sub duration_string { my ( $problem, $c ) = @_; - my $body; - if ( $c->cobrand->can('link_to_council_cobrand') ) { - $body = $c->cobrand->link_to_council_cobrand($problem); - } else { - $body = $problem->body( $c ); - } - if ( $c->cobrand->can('get_body_handler_for_problem') ) { - my $handler = $c->cobrand->get_body_handler_for_problem( $problem ); - if ( $handler->can('is_council_with_case_management') && $handler->is_council_with_case_management ) { - return sprintf(_('Received by %s moments later'), $body); - } + my $body = $c->cobrand->call_hook(link_to_council_cobrand => $problem) || $problem->body($c); + my $handler = $c->cobrand->call_hook(get_body_handler_for_problem => $problem); + if ( $handler && $handler->call_hook('is_council_with_case_management') ) { + return sprintf(_('Received by %s moments later'), $body); } return unless $problem->whensent; return sprintf(_('Sent to %s %s later'), $body, @@ -1048,4 +1136,24 @@ has duplicates => ( }, ); +has traffic_management_options => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $cobrand = $self->get_cobrand_logged; + $cobrand = $cobrand->call_hook(get_body_handler_for_problem => $self) || $cobrand; + return $cobrand->traffic_management_options; + }, +); + +has inspection_log_entry => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + return $self->admin_log_entries->search({ action => 'inspected' }, { order_by => { -desc => 'whenedited' } })->first; + }, +); + 1; diff --git a/perllib/FixMyStreet/DB/Result/ResponsePriority.pm b/perllib/FixMyStreet/DB/Result/ResponsePriority.pm index 6bc8474fa..44635d174 100644 --- a/perllib/FixMyStreet/DB/Result/ResponsePriority.pm +++ b/perllib/FixMyStreet/DB/Result/ResponsePriority.pm @@ -26,6 +26,8 @@ __PACKAGE__->add_columns( { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "description", { data_type => "text", is_nullable => 1 }, + "external_id", + { data_type => "text", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->add_unique_constraint("response_priorities_body_id_name_key", ["body_id", "name"]); @@ -49,8 +51,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-10-17 16:37:28 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:wok3cPA7cPjG4e9lnc1PIg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-12-14 17:12:09 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:glsO0fLK6fNvg4TmW1DMPg __PACKAGE__->many_to_many( contacts => 'contact_response_priorities', 'contact' ); diff --git a/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm index 0d4377dba..5a2029eb1 100644 --- a/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm +++ b/perllib/FixMyStreet/DB/Result/ResponseTemplate.pm @@ -33,6 +33,8 @@ __PACKAGE__->add_columns( }, "auto_response", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "state", + { data_type => "text", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->add_unique_constraint("response_templates_body_id_title_key", ["body_id", "title"]); @@ -50,8 +52,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-08-24 11:29:04 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:KRm0RHbtrzuxzH0S/UAsdw +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2016-12-01 15:10:52 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ySPzQpFwJNki8XBjCNiqZQ __PACKAGE__->many_to_many( contacts => 'contact_response_templates', 'contact' ); diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index f4e5144f8..cf6de9a76 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -248,6 +248,15 @@ sub split_name { return { first => $first || '', last => $last || '' }; } +has body_permissions => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + return [ $self->user_body_permissions->all ]; + }, +); + sub permissions { my ($self, $c, $body_id) = @_; @@ -258,9 +267,7 @@ sub permissions { return unless $self->belongs_to_body($body_id); - my @permissions = $self->user_body_permissions->search({ - body_id => $self->from_body->id, - })->all; + my @permissions = grep { $_->body_id == $self->from_body->id } @{$self->body_permissions}; return { map { $_->permission_type => 1 } @permissions }; } @@ -269,33 +276,37 @@ sub has_permission_to { return 1 if $self->is_superuser; return 0 if !$body_ids || (ref $body_ids && !@$body_ids); + $body_ids = [ $body_ids ] unless ref $body_ids; + my %body_ids = map { $_ => 1 } @$body_ids; - my $permission = $self->user_body_permissions->find({ - permission_type => $permission_type, - body_id => $body_ids, - }); - return $permission ? 1 : 0; + foreach (@{$self->body_permissions}) { + return 1 if $_->permission_type eq $permission_type && $body_ids{$_->body_id}; + } + return 0; } =head2 has_body_permission_to -Checks if the User has a from_body set, and the specified permission on that body. +Checks if the User has a from_body set, the specified permission on that body, +and optionally that their from_body is one particular body. Instead of saying: - ($user->from_body && $user->has_permission_to('user_edit', $user->from_body->id)) + ($user->from_body && $user->from_body->id == $body_id && $user->has_permission_to('user_edit', $body_id)) You can just say: - $user->has_body_permission_to('user_edit') - -NB unlike has_permission_to, this doesn't blindly return 1 if the user is a superuser. + $user->has_body_permission_to('user_edit', $body_id) =cut sub has_body_permission_to { - my ($self, $permission_type) = @_; + my ($self, $permission_type, $body_id) = @_; + + return 1 if $self->is_superuser; + return unless $self->from_body; + return if $body_id && $self->from_body->id != $body_id; return $self->has_permission_to($permission_type, $self->from_body->id); } @@ -371,6 +382,8 @@ around add_to_planned_reports => sub { around remove_from_planned_reports => sub { my ($orig, $self, $report) = @_; $self->user_planned_reports->active->for_report($report->id)->remove(); + $report->unset_extra_metadata('order'); + $report->update; }; sub active_planned_reports { diff --git a/perllib/FixMyStreet/DB/ResultSet/DefectType.pm b/perllib/FixMyStreet/DB/ResultSet/DefectType.pm new file mode 100644 index 000000000..a873ef252 --- /dev/null +++ b/perllib/FixMyStreet/DB/ResultSet/DefectType.pm @@ -0,0 +1,22 @@ +package FixMyStreet::DB::ResultSet::DefectType; +use base 'DBIx::Class::ResultSet'; + +use strict; +use warnings; + +sub for_bodies { + my ($rs, $bodies, $category) = @_; + my $attrs = { + 'me.body_id' => $bodies, + }; + if ($category) { + $attrs->{'contact.category'} = [ $category, undef ]; + } + $rs->search($attrs, { + order_by => 'name', + join => { 'contact_defect_types' => 'contact' }, + distinct => 1, + }); +} + +1; diff --git a/perllib/FixMyStreet/Email.pm b/perllib/FixMyStreet/Email.pm index 7d81c9dc5..ea84e3966 100644 --- a/perllib/FixMyStreet/Email.pm +++ b/perllib/FixMyStreet/Email.pm @@ -17,7 +17,7 @@ use mySociety::Random qw(random_bytes); use Utils::Email; use FixMyStreet; use FixMyStreet::DB; -use FixMyStreet::EmailSend; +use FixMyStreet::Email::Sender; sub test_dmarc { my $email = shift; @@ -77,6 +77,10 @@ sub _render_template { return $var; } +sub unique_verp_id { + sprintf('fms-%s@%s', generate_verp_token(@_), FixMyStreet->config('EMAIL_DOMAIN')); +} + sub _unique_id { sprintf('fms-%s-%s@%s', time(), unpack('h*', random_bytes(5, 1)), @@ -183,7 +187,7 @@ sub send_cron { print $email->as_string; return 1; # Failure } else { - my $result = FixMyStreet::EmailSend->new({ env_from => $env_from })->send($email); + my $result = FixMyStreet::Email::Sender->try_to_send($email, { from => $env_from }); return $result ? 0 : 1; } } diff --git a/perllib/FixMyStreet/Email/Sender.pm b/perllib/FixMyStreet/Email/Sender.pm new file mode 100644 index 000000000..e6148a56c --- /dev/null +++ b/perllib/FixMyStreet/Email/Sender.pm @@ -0,0 +1,50 @@ +package FixMyStreet::Email::Sender; + +use parent Email::Sender::Simple; +use strict; +use warnings; + +use Email::Sender::Util; +use FixMyStreet; + +=head1 NAME + +FixMyStreet::Email::Sender + +=head1 DESCRIPTION + +Subclass of Email::Sender - configuring it correctly according to our config. + +If the config value 'SMTP_SMARTHOST' is set then email is routed via SMTP to +that. Otherwise it is sent using a 'sendmail' like binary on the local system. + +And finally if if FixMyStreet->test_mode returns true then emails are not sent +at all but are stored in memory for the test suite to inspect (using +Email::Send::Test). + +=cut + +sub build_default_transport { + if ( FixMyStreet->test_mode ) { + Email::Sender::Util->easy_transport(Test => {}); + } elsif ( my $smtp_host = FixMyStreet->config('SMTP_SMARTHOST') ) { + my $type = FixMyStreet->config('SMTP_TYPE') || ''; + my $port = FixMyStreet->config('SMTP_PORT') || ''; + my $username = FixMyStreet->config('SMTP_USERNAME') || ''; + my $password = FixMyStreet->config('SMTP_PASSWORD') || ''; + + my $ssl = $type eq 'tls' ? 'starttls' : $type eq 'ssl' ? 'ssl' : ''; + my $args = { + host => $smtp_host, + ssl => $ssl, + sasl_username => $username, + sasl_password => $password, + }; + $args->{port} = $port if $port; + Email::Sender::Util->easy_transport(SMTP => $args); + } else { + Email::Sender::Util->easy_transport(Sendmail => {}); + } +} + +1; diff --git a/perllib/FixMyStreet/EmailSend.pm b/perllib/FixMyStreet/EmailSend.pm deleted file mode 100644 index 09f434931..000000000 --- a/perllib/FixMyStreet/EmailSend.pm +++ /dev/null @@ -1,78 +0,0 @@ -package FixMyStreet::EmailSend; - -use strict; -use warnings; - -BEGIN { - # Should move away from Email::Send, but until then: - $Return::Value::NO_CLUCK = 1; -} - -use FixMyStreet; -use Email::Send; - -=head1 NAME - -FixMyStreet::EmailSend - -=head1 DESCRIPTION - -Thin wrapper around Email::Send - configuring it correctly according to our config. - -If the config value 'SMTP_SMARTHOST' is set then email is routed via SMTP to -that. Otherwise it is sent using a 'sendmail' like binary on the local system. - -And finally if if FixMyStreet->test_mode returns true then emails are not sent -at all but are stored in memory for the test suite to inspect (using -Email::Send::Test). - -=cut - -my $args = undef; - -if ( FixMyStreet->test_mode ) { - # Email::Send::Test - $args = { mailer => 'Test', }; -} elsif ( my $smtp_host = FixMyStreet->config('SMTP_SMARTHOST') ) { - # Email::Send::SMTP - my $type = FixMyStreet->config('SMTP_TYPE') || ''; - my $port = FixMyStreet->config('SMTP_PORT') || ''; - my $username = FixMyStreet->config('SMTP_USERNAME') || ''; - my $password = FixMyStreet->config('SMTP_PASSWORD') || ''; - - unless ($port) { - $port = 25; - $port = 465 if $type eq 'ssl'; - $port = 587 if $type eq 'tls'; - } - - my $mailer_args = [ - Host => $smtp_host, - Port => $port, - ]; - push @$mailer_args, ssl => 1 if $type eq 'ssl'; - push @$mailer_args, tls => 1 if $type eq 'tls'; - push @$mailer_args, username => $username, password => $password - if $username && $password; - $args = { - mailer => 'FixMyStreet::EmailSend::Variable', - mailer_args => $mailer_args, - }; -} else { - # Email::Send::Sendmail - $args = { mailer => 'Sendmail' }; -} - -sub new { - my ($cls, $hash) = @_; - $hash ||= {}; - my %args = ( %$args, %$hash ); - - my $sender = delete($args{env_from}); - if ($sender) { - $args{mailer_args} = [ @{$args{mailer_args}} ] if $args{mailer_args}; - push @{$args{mailer_args}}, env_from => $sender; - } - - return Email::Send->new(\%args); -} diff --git a/perllib/FixMyStreet/EmailSend/Variable.pm b/perllib/FixMyStreet/EmailSend/Variable.pm deleted file mode 100644 index 4ba56dd41..000000000 --- a/perllib/FixMyStreet/EmailSend/Variable.pm +++ /dev/null @@ -1,17 +0,0 @@ -package FixMyStreet::EmailSend::Variable; -use base Email::Send::SMTP; -use FixMyStreet; - -my $sender; - -sub send { - my ($class, $message, %args) = @_; - $sender = delete($args{env_from}) || FixMyStreet->config('DO_NOT_REPLY_EMAIL'); - $class->SUPER::send($message, %args); -} - -sub get_env_sender { - $sender; -} - -1; diff --git a/perllib/FixMyStreet/Script/Alerts.pm b/perllib/FixMyStreet/Script/Alerts.pm index 65183c09c..1a760a0c1 100644 --- a/perllib/FixMyStreet/Script/Alerts.pm +++ b/perllib/FixMyStreet/Script/Alerts.pm @@ -283,11 +283,7 @@ sub _send_aggregated_alert_email(%) { } ); $data{unsubscribe_url} = $cobrand->base_url( $data{cobrand_data} ) . '/A/' . $token->token; - my $sender = sprintf('<fms-%s@%s>', - FixMyStreet::Email::generate_verp_token('alert', $data{alert_id}), - FixMyStreet->config('EMAIL_DOMAIN') - ); - + my $sender = FixMyStreet::Email::unique_verp_id('alert', $data{alert_id}); my $result = FixMyStreet::Email::send_cron( $data{schema}, "$data{template}.txt", diff --git a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm new file mode 100644 index 000000000..5d1d45379 --- /dev/null +++ b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm @@ -0,0 +1,141 @@ +package FixMyStreet::Script::ArchiveOldEnquiries; + +use strict; +use warnings; +require 5.8.0; + +use FixMyStreet; +use FixMyStreet::App; +use FixMyStreet::DB; +use FixMyStreet::Cobrand; +use FixMyStreet::Map; +use FixMyStreet::Email; + + +my $opts = { + commit => 0, + body => '2237', + cobrand => 'oxfordshire', + closure_cutoff => "2015-01-01 00:00:00", + email_cutoff => "2016-01-01 00:00:00", +}; + +sub query { + return { + bodies_str => { 'LIKE', "%".$opts->{body}."%"}, + -and => [ + lastupdate => { '<', $opts->{email_cutoff} }, + lastupdate => { '>', $opts->{closure_cutoff} }, + ], + state => [ FixMyStreet::DB::Result::Problem->open_states() ], + }; +} + +sub archive { + my $params = shift; + if ( $params ) { + $opts = { + %$opts, + %$params, + }; + } + + unless ( $opts->{commit} ) { + printf "Doing a dry run; emails won't be sent and reports won't be closed.\n"; + printf "Re-run with --commit to actually archive reports.\n\n"; + } + + my @user_ids = FixMyStreet::DB->resultset('Problem')->search(query(), + { + distinct => 1, + columns => ['user_id'], + rows => $opts->{limit}, + })->all; + + @user_ids = map { $_->user_id } @user_ids; + + my $users = FixMyStreet::DB->resultset('User')->search({ + id => \@user_ids + }); + + my $user_count = $users->count; + my $problem_count = FixMyStreet::DB->resultset('Problem')->search(query(), + { + columns => ['id'], + rows => $opts->{limit}, + })->count; + + printf("%d users will receive closure emails about %d reports which will be closed.\n", $user_count, $problem_count); + + if ( $opts->{commit} ) { + my $i = 0; + while ( my $user = $users->next ) { + printf("%d/%d: User ID %d\n", ++$i, $user_count, $user->id); + send_email_and_close($user); + } + } + + my $problems_to_close = FixMyStreet::DB->resultset('Problem')->search({ + bodies_str => { 'LIKE', "%".$opts->{body}."%"}, + lastupdate => { '<', $opts->{closure_cutoff} }, + state => [ FixMyStreet::DB::Result::Problem->open_states() ], + }, { + rows => $opts->{limit}, + }); + + printf("Closing %d old reports, without sending emails: ", $problems_to_close->count); + + if ( $opts->{commit} ) { + $problems_to_close->update({ state => 'closed', send_questionnaire => 0 }); + } + + printf("done.\n") +} + +sub send_email_and_close { + my ($user) = @_; + + my $problems = $user->problems->search(query(), { + order_by => { -desc => 'confirmed' }, + }); + + my @problems = $problems->all; + + return if scalar(@problems) == 0; + + my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker($opts->{cobrand})->new(); + $cobrand->set_lang_and_domain($problems[0]->lang, 1); + FixMyStreet::Map::set_map_class($cobrand->map_type); + + my %h = ( + reports => [@problems], + report_count => scalar(@problems), + site_name => $cobrand->moniker, + user => $user, + cobrand => $cobrand, + ); + + # Send email + printf(" Sending email about %d reports: ", scalar(@problems)); + my $email_error = FixMyStreet::Email::send_cron( + $problems->result_source->schema, + 'archive.txt', + \%h, + { + To => [ [ $user->email, $user->name ] ], + }, + undef, + undef, + $cobrand, + $problems[0]->lang, + ); + + unless ( $email_error ) { + printf("done.\n Closing reports: "); + + $problems->update({ state => 'closed', send_questionnaire => 0 }); + printf("done.\n"); + } else { + printf("error! Not closing reports for this user.\n") + } +} diff --git a/perllib/FixMyStreet/Script/Reports.pm b/perllib/FixMyStreet/Script/Reports.pm index 7d614bc30..6057807de 100644 --- a/perllib/FixMyStreet/Script/Reports.pm +++ b/perllib/FixMyStreet/Script/Reports.pm @@ -28,7 +28,7 @@ sub send(;$) { my $base_url = FixMyStreet->config('BASE_URL'); my $site = $site_override || CronFns::site($base_url); - my $states = [ 'confirmed', 'fixed' ]; + my $states = [ FixMyStreet::DB::Result::Problem::open_states() ]; $states = [ 'unconfirmed', 'confirmed', 'in progress', 'planned', 'closed', 'investigating' ] if $site eq 'zurich'; my $unsent = $rs->search( { state => $states, @@ -112,9 +112,7 @@ sub send(;$) { $h{user_details} .= sprintf(_('Email: %s'), $row->user->email) . "\n\n"; } - if ($cobrand->can('process_additional_metadata_for_email')) { - $cobrand->process_additional_metadata_for_email($row, \%h); - } + $cobrand->call_hook(process_additional_metadata_for_email => $row, \%h); my $bodies = FixMyStreet::DB->resultset('Body')->search( { id => $row->bodies_str_ids }, @@ -211,7 +209,7 @@ sub send(;$) { . " ]\n\n"; } - if (FixMyStreet->config('STAGING_SITE') && !FixMyStreet->config('SEND_REPORTS_ON_STAGING')) { + if (FixMyStreet->staging_flag('send_reports', 0)) { # on a staging server send emails to ourselves rather than the bodies %reporters = map { $_ => $reporters{$_} } grep { /FixMyStreet::SendReport::Email/ } keys %reporters; unless (%reporters) { @@ -276,7 +274,7 @@ sub send(;$) { } my $sending_errors = ''; my $unsent = $rs->search( { - state => [ 'confirmed', 'fixed' ], + state => [ FixMyStreet::DB::Result::Problem::open_states() ], whensent => undef, bodies_str => { '!=', undef }, send_fail_count => { '>', 0 } diff --git a/perllib/FixMyStreet/SendReport/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm index 2eab1c754..28f3411d0 100644 --- a/perllib/FixMyStreet/SendReport/Email.pm +++ b/perllib/FixMyStreet/SendReport/Email.pm @@ -2,6 +2,7 @@ package FixMyStreet::SendReport::Email; use Moo; use FixMyStreet::Email; +use Utils::Email; BEGIN { extends 'FixMyStreet::SendReport'; } @@ -28,13 +29,6 @@ sub build_recipient_list { $self->unconfirmed_notes->{$body_email}{$row->category} = $note; } - my $body_name = $body->name; - # see something uses council areas but doesn't send to councils so just use a - # generic name here to minimise confusion - if ( $row->cobrand eq 'seesomething' ) { - $body_name = 'See Something, Say Something'; - } - my @emails; # allow multiple emails per contact if ( $body_email =~ /,/ ) { @@ -43,7 +37,7 @@ sub build_recipient_list { @emails = ( $body_email ); } for my $email ( @emails ) { - push @{ $self->to }, [ $email, $body_name ]; + push @{ $self->to }, [ $email, $body->name ]; } } @@ -67,7 +61,7 @@ sub send { my $recips = $self->build_recipient_list( $row, $h ); # on a staging server send emails to ourselves rather than the bodies - if (FixMyStreet->config('STAGING_SITE') && !FixMyStreet->config('SEND_REPORTS_ON_STAGING') && !FixMyStreet->test_mode) { + if (FixMyStreet->staging_flag('send_reports', 0) && !FixMyStreet->test_mode) { $recips = 1; @{$self->to} = [ $row->user->email, $self->to->[0][1] || $row->name ]; } @@ -84,16 +78,14 @@ sub send { From => $self->send_from( $row ), }; - $cobrand->munge_sendreport_params($row, $h, $params) if $cobrand->can('munge_sendreport_params'); + $cobrand->call_hook(munge_sendreport_params => $row, $h, $params); $params->{Bcc} = $self->bcc if @{$self->bcc}; - my $sender = sprintf('<fms-%s@%s>', - FixMyStreet::Email::generate_verp_token('report', $row->id), - FixMyStreet->config('EMAIL_DOMAIN') - ); + my $sender = FixMyStreet::Email::unique_verp_id('report', $row->id); - if (FixMyStreet::Email::test_dmarc($params->{From}[0])) { + if (FixMyStreet::Email::test_dmarc($params->{From}[0]) + || Utils::Email::same_domain($params->{From}, $params->{To})) { $params->{'Reply-To'} = [ $params->{From} ]; $params->{From} = [ $sender, $params->{From}[1] ]; } diff --git a/perllib/FixMyStreet/SendReport/Open311.pm b/perllib/FixMyStreet/SendReport/Open311.pm index ee40f371a..059690612 100644 --- a/perllib/FixMyStreet/SendReport/Open311.pm +++ b/perllib/FixMyStreet/SendReport/Open311.pm @@ -5,14 +5,7 @@ use namespace::autoclean; BEGIN { extends 'FixMyStreet::SendReport'; } -use DateTime::Format::W3CDTF; use Open311; -use Readonly; - -Readonly::Scalar my $COUNCIL_ID_OXFORDSHIRE => 2237; -Readonly::Scalar my $COUNCIL_ID_WARWICKSHIRE => 2243; -Readonly::Scalar my $COUNCIL_ID_GREENWICH => 2493; -Readonly::Scalar my $COUNCIL_ID_BROMLEY => 2482; has open311_test_req_used => ( is => 'rw', @@ -27,47 +20,18 @@ sub send { foreach my $body ( @{ $self->bodies } ) { my $conf = $self->body_config->{ $body->id }; - my $always_send_latlong = 1; - my $send_notpinpointed = 0; - my $use_service_as_deviceid = 0; - - my $extended_desc = 1; - - my $extra = $row->get_extra_fields(); + my %open311_params = ( + jurisdiction => $conf->jurisdiction, + endpoint => $conf->endpoint, + api_key => $conf->api_key, + always_send_latlong => 1, + send_notpinpointed => 0, + use_service_as_deviceid => 0, + extended_description => 1, + ); - # Extra bromley fields - if ( $row->bodies_str eq $COUNCIL_ID_BROMLEY ) { - push @$extra, { name => 'report_url', value => $h->{url} }; - push @$extra, { name => 'report_title', value => $row->title }; - push @$extra, { name => 'public_anonymity_required', value => $row->anonymous ? 'TRUE' : 'FALSE' }; - push @$extra, { name => 'email_alerts_requested', value => 'FALSE' }; # always false as can never request them - push @$extra, { name => 'requested_datetime', value => DateTime::Format::W3CDTF->format_datetime($row->confirmed->set_nanosecond(0)) }; - push @$extra, { name => 'email', value => $row->user->email }; - # make sure we have last_name attribute present in row's extra, so - # it is passed correctly to Bromley as attribute[] - if ( $row->cobrand ne 'bromley' ) { - my ( $firstname, $lastname ) = ( $row->name =~ /(\w+)\.?\s+(.+)/ ); - push @$extra, { name => 'last_name', value => $lastname }; - } - $always_send_latlong = 0; - $send_notpinpointed = 1; - $extended_desc = 0; - } elsif ( $row->bodies_str =~ /\b$COUNCIL_ID_OXFORDSHIRE\b/ ) { - # Oxfordshire doesn't have category metadata to fill these - $extended_desc = 'oxfordshire'; - push @$extra, { name => 'external_id', value => $row->id }; - push @$extra, { name => 'closest_address', value => $h->{closest_address} } if $h->{closest_address}; - if ( $row->used_map || ( !$row->used_map && !$row->postcode ) ) { - push @$extra, { name => 'northing', value => $h->{northing} }; - push @$extra, { name => 'easting', value => $h->{easting} }; - } - } elsif ( $row->bodies_str =~ /\b$COUNCIL_ID_WARWICKSHIRE\b/ ) { - $extended_desc = 'warwickshire'; - push @$extra, { name => 'closest_address', value => $h->{closest_address} } if $h->{closest_address}; - } elsif ( $row->bodies_str == $COUNCIL_ID_GREENWICH ) { - # Greenwich doesn't have category metadata to fill this - push @$extra, { name => 'external_id', value => $row->id }; - } + my $cobrand = $body->get_cobrand_handler || $row->get_cobrand_logged; + $cobrand->call_hook(open311_config => $row, $h, \%open311_params); # Try and fill in some ones that we've been asked for, but not asked the user for @@ -77,10 +41,14 @@ sub send { category => $row->category } ); + my $extra = $row->get_extra_fields(); + my $id_field = $contact->id_field; foreach (@{$contact->get_extra_fields}) { if ($_->{code} eq $id_field) { push @$extra, { name => $id_field, value => $row->id }; + } elsif ($_->{code} eq 'closest_address' && $h->{closest_address}) { + push @$extra, { name => $_->{code}, value => $h->{$_->{code}} }; } elsif ($_->{code} =~ /^(easting|northing)$/) { if ( $row->used_map || ( !$row->used_map && !$row->postcode ) ) { push @$extra, { name => $_->{code}, value => $h->{$_->{code}} }; @@ -90,15 +58,6 @@ sub send { $row->set_extra_fields( @$extra ) if @$extra; - my %open311_params = ( - jurisdiction => $conf->jurisdiction, - endpoint => $conf->endpoint, - api_key => $conf->api_key, - always_send_latlong => $always_send_latlong, - send_notpinpointed => $send_notpinpointed, - use_service_as_deviceid => $use_service_as_deviceid, - extended_description => $extended_desc, - ); if (FixMyStreet->test_mode) { my $test_res = HTTP::Response->new(); $test_res->code(200); @@ -110,20 +69,7 @@ sub send { my $open311 = Open311->new( %open311_params ); - # non standard west berks end points - if ( $row->bodies_str =~ /2619/ ) { - $open311->endpoints( { services => 'Services', requests => 'Requests' } ); - } - - # non-standard Oxfordshire endpoint (because it's just a script, not a full Open311 service) - if ( $row->bodies_str =~ /$COUNCIL_ID_OXFORDSHIRE/ ) { - $open311->endpoints( { requests => 'open311_service_request.cgi' } ); - } - - # required to get round issues with CRM constraints - if ( $row->bodies_str =~ /2218/ ) { - $row->user->name( $row->user->id . ' ' . $row->user->name ); - } + $cobrand->call_hook(open311_pre_send => $row, $open311); my $resp = $open311->send_service_request( $row, $h, $contact->email ); if (FixMyStreet->test_mode) { diff --git a/perllib/FixMyStreet/Template.pm b/perllib/FixMyStreet/Template.pm index f41d11b69..4a9cffecb 100644 --- a/perllib/FixMyStreet/Template.pm +++ b/perllib/FixMyStreet/Template.pm @@ -62,7 +62,7 @@ sub loc : Fn { =head2 nget - [% nget( 'singular', 'plural', $number ) %] + [% nget( 'singular', 'plural', $number ) %] Use first or second string depending on the number. diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm index 122a5d0c9..166ba116f 100644 --- a/perllib/FixMyStreet/TestMech.pm +++ b/perllib/FixMyStreet/TestMech.pm @@ -10,10 +10,11 @@ BEGIN { } use Test::WWW::Mechanize::Catalyst 'FixMyStreet::App'; +use t::Mock::MapIt; use Test::More; use Web::Scraper; use Carp; -use Email::Send::Test; +use FixMyStreet::Email::Sender; use JSON::MaybeXS; =head1 NAME @@ -182,7 +183,7 @@ Clear the email queue. sub clear_emails_ok { my $mech = shift; - Email::Send::Test->clear; + FixMyStreet::Email::Sender->default_transport->clear_deliveries; $mech->builder->ok( 1, 'cleared email queue' ); return 1; } @@ -199,7 +200,7 @@ sub email_count_is { my $mech = shift; my $number = shift || 0; - $mech->builder->is_num( scalar( Email::Send::Test->emails ), + $mech->builder->is_num( scalar( FixMyStreet::Email::Sender->default_transport->delivery_count ), $number, "checking for $number email(s) in the queue" ); } @@ -215,7 +216,8 @@ In list context returns all the emails (or none). sub get_email { my $mech = shift; - my @emails = Email::Send::Test->emails; + my @emails = FixMyStreet::Email::Sender->default_transport->deliveries; + @emails = map { $_->{email}->object } @emails; return @emails if wantarray; @@ -610,6 +612,7 @@ sub delete_body { my $body = shift; $mech->delete_problems_for_body($body->id); + $mech->delete_defect_type($_) for $body->defect_types; $mech->delete_contact($_) for $body->contacts; $mech->delete_user($_) for $body->users; $_->delete for $body->response_templates; @@ -641,6 +644,14 @@ sub delete_problems_for_body { } } +sub delete_defect_type { + my $mech = shift; + my $defect_type = shift; + + $defect_type->contact_defect_types->delete_all; + $defect_type->delete; +} + sub create_contact_ok { my $self = shift; my %contact_params = ( diff --git a/perllib/Open311.pm b/perllib/Open311.pm index 0b44575db..c1323acfa 100644 --- a/perllib/Open311.pm +++ b/perllib/Open311.pm @@ -83,10 +83,10 @@ sub send_service_request { } warn sprintf( "Failed to submit problem %s over Open311, response\n: %s\n%s", $problem->id, $response, $self->debug_details ) - unless $problem->send_fail_count; + if $problem->send_fail_count && $problem->send_fail_count == 2; } else { warn sprintf( "Failed to submit problem %s over Open311, details:\n%s", $problem->id, $self->error) - unless $problem->send_fail_count; + if $problem->send_fail_count && $problem->send_fail_count == 2; } return 0; } @@ -259,10 +259,10 @@ sub post_service_request_update { } warn sprintf( "Failed to submit comment %s over Open311, response - %s\n%s\n", $comment->id, $response, $self->debug_details ) - unless $comment->send_fail_count; + if $comment->send_fail_count && $comment->send_fail_count == 2; } else { warn sprintf( "Failed to submit comment %s over Open311, details\n%s\n", $comment->id, $self->error) - unless $comment->send_fail_count; + if $comment->send_fail_count && $comment->send_fail_count == 2; } return 0; } diff --git a/perllib/Utils.pm b/perllib/Utils.pm index 7dd2a3f39..6ba20e9d3 100644 --- a/perllib/Utils.pm +++ b/perllib/Utils.pm @@ -99,7 +99,7 @@ sub truncate_coordinate { Strip leading and trailing white space from a string. Also reduces all white space to a single space. -Trim +Trim =cut @@ -195,7 +195,28 @@ sub prettify_dt { # argument is duration in seconds, rounds to the nearest minute sub prettify_duration { my ($s, $nearest) = @_; - if ($nearest eq 'week') { + + unless ( defined $nearest ) { + if ($s < 3600) { + $nearest = 'minute'; + } elsif ($s < 3600*24) { + $nearest = 'hour'; + } elsif ($s < 3600*24*7) { + $nearest = 'day'; + } elsif ($s < 3600*24*7*4) { + $nearest = 'week'; + } elsif ($s < 3600*24*7*4*12) { + $nearest = 'month'; + } else { + $nearest = 'year'; + } + } + + if ($nearest eq 'year') { + $s = int(($s+60*60*24*3.5)/60/60/24/7/4/12)*60*60*24*7*4*12; + } elsif ($nearest eq 'month') { + $s = int(($s+60*60*24*3.5)/60/60/24/7/4)*60*60*24*7*4; + } elsif ($nearest eq 'week') { $s = int(($s+60*60*24*3.5)/60/60/24/7)*60*60*24*7; } elsif ($nearest eq 'day') { $s = int(($s+60*60*12)/60/60/24)*60*60*24; @@ -206,6 +227,8 @@ sub prettify_duration { return _('less than a minute') if $s == 0; } my @out = (); + _part(\$s, 60*60*24*7*4*12, \@out); + _part(\$s, 60*60*24*7*4, \@out); _part(\$s, 60*60*24*7, \@out); _part(\$s, 60*60*24, \@out); _part(\$s, 60*60, \@out); @@ -217,7 +240,11 @@ sub _part { if ($$s >= $m) { my $i = int($$s / $m); my $str; - if ($m == 60*60*24*7) { + if ($m == 60*60*24*7*4*12) { + $str = mySociety::Locale::nget("%d year", "%d years", $i); + } elsif ($m == 60*60*24*7*4) { + $str = mySociety::Locale::nget("%d month", "%d months", $i); + } elsif ($m == 60*60*24*7) { $str = mySociety::Locale::nget("%d week", "%d weeks", $i); } elsif ($m == 60*60*24) { $str = mySociety::Locale::nget("%d day", "%d days", $i); diff --git a/perllib/Utils/Email.pm b/perllib/Utils/Email.pm index a30e41c61..5e3df0205 100644 --- a/perllib/Utils/Email.pm +++ b/perllib/Utils/Email.pm @@ -34,4 +34,16 @@ sub _send { return grep { $_->type eq $type } @answers; } +sub same_domain { + my ($email, $list) = @_; + my $addr = (Email::Address->parse($email->[0]))[0]; + return unless $addr; + my $domain = $addr->host; + foreach (@$list) { + my $addr = (Email::Address->parse($_->[0]))[0]; + next unless $addr; + return 1 if $domain eq $addr->host; + } +} + 1; |