diff options
author | Matthew Somerville <matthew@mysociety.org> | 2019-11-10 19:27:06 +0000 |
---|---|---|
committer | Matthew Somerville <matthew@mysociety.org> | 2019-11-10 19:27:06 +0000 |
commit | d8dd060a4c2f75e89a24f99634d91a6d8ef0e2bc (patch) | |
tree | abb543c32c9e5f239cc66cff1bafa697f322ebd3 | |
parent | db61249c59a96a2fad80523288b7d13881c10965 (diff) | |
parent | b886792181eb77206054e73315a9d14cdb17e936 (diff) |
Merge branch 'admin-auditing'
41 files changed, 1039 insertions, 715 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3c40560..1f2f3ed3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - Allow editing of category name. #1398 - Allow non-superuser staff to use 2FA, and optional enforcement of 2FA. - Add optional enforced password expiry. + - Store a moderation history on admin report edit. + - Add user admin log page. - New features: - Categories can be listed under more than one group #2475 - OpenID Connect login support. #2523 diff --git a/bin/update-schema b/bin/update-schema index 3f4b2bafe..4755142bb 100755 --- a/bin/update-schema +++ b/bin/update-schema @@ -212,6 +212,7 @@ else { # (assuming schema change files are never half-applied, which should be the case) sub get_db_version { return 'EMPTY' if ! table_exists('problem'); + return '0069' if constraint_contains('admin_log_object_type_check', 'template'); return '0068' if column_exists('users', 'oidc_ids'); return '0067' if table_exists('roles'); return '0066' if column_exists('users', 'area_ids'); diff --git a/db/schema.sql b/db/schema.sql index a211ef50d..cf2914467 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -445,6 +445,10 @@ create table admin_log ( or object_type = 'update' or object_type = 'user' or object_type = 'moderation' + or object_type = 'template' + or object_type = 'body' + or object_type = 'category' + or object_type = 'role' ), object_id integer not null, action text not null, diff --git a/db/schema_0069-admin-log-types.sql b/db/schema_0069-admin-log-types.sql new file mode 100644 index 000000000..e4e9362ac --- /dev/null +++ b/db/schema_0069-admin-log-types.sql @@ -0,0 +1,17 @@ +BEGIN; + +ALTER TABLE admin_log DROP CONSTRAINT admin_log_object_type_check; + +ALTER TABLE admin_log ADD CONSTRAINT admin_log_object_type_check CHECK ( + object_type = 'problem' + OR object_type = 'update' + OR object_type = 'user' + OR object_type = 'moderation' + OR object_type = 'template' + OR object_type = 'body' + OR object_type = 'category' + OR object_type = 'role' +); + +COMMIT; + diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm index b00c34777..975fed02f 100644 --- a/perllib/FixMyStreet/App/Controller/Admin.pm +++ b/perllib/FixMyStreet/App/Controller/Admin.pm @@ -4,20 +4,9 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } -use Path::Class; -use POSIX qw(strftime strcoll); -use Digest::SHA qw(sha1_hex); -use mySociety::EmailUtil qw(is_valid_email is_valid_email_list); -use DateTime::Format::Strptime; +use POSIX qw(strcoll); use List::Util 'first'; -use List::MoreUtils 'uniq'; -use mySociety::ArrayUtils; -use Text::CSV; -use Try::Tiny; - -use FixMyStreet::SendReport; use FixMyStreet::SMS; -use Utils; =head1 NAME @@ -214,157 +203,6 @@ sub fetch_languages : Private { return 1; } -sub reports : Path('reports') { - my ( $self, $c ) = @_; - - $c->stash->{edit_body_contacts} = 1 - if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}}; - - my $query = {}; - if ( $c->cobrand->moniker eq 'zurich' ) { - my $type = $c->stash->{admin_type}; - my $body = $c->stash->{body}; - if ( $type eq 'dm' ) { - my @children = map { $_->id } $body->bodies->all; - my @all = (@children, $body->id); - $query = { bodies_str => \@all }; - } elsif ( $type eq 'sdm' ) { - $query = { bodies_str => $body->id }; - } - } - - my $order = $c->get_param('o') || 'id'; - my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1; - $c->stash->{order} = $order; - $c->stash->{dir} = $dir; - $order = $dir ? { -desc => "me.$order" } : "me.$order"; - - my $p_page = $c->get_param('p') || 1; - my $u_page = $c->get_param('u') || 1; - - return if $c->cobrand->call_hook(report_search_query => $query, $p_page, $u_page, $order); - - if (my $search = $c->get_param('search')) { - $search = $self->trim($search); - - # In case an email address, wrapped in <...> - if ($search =~ /^<(.*)>$/) { - my $possible_email = $1; - my $parsed = FixMyStreet::SMS->parse_username($possible_email); - $search = $possible_email if $parsed->{email}; - } - - $c->stash->{searched} = $search; - - my $search_n = 0; - $search_n = int($search) if $search =~ /^\d+$/; - - my $like_search = "%$search%"; - - my $parsed = FixMyStreet::SMS->parse_username($search); - my $valid_phone = $parsed->{phone}; - my $valid_email = $parsed->{email}; - - if ($valid_email) { - $query->{'-or'} = [ - 'user.email' => { ilike => $like_search }, - ]; - } elsif ($valid_phone) { - $query->{'-or'} = [ - 'user.phone' => { ilike => $like_search }, - ]; - } elsif ($search =~ /^id:(\d+)$/) { - $query->{'-or'} = [ - 'me.id' => int($1), - ]; - } elsif ($search =~ /^area:(\d+)$/) { - $query->{'-or'} = [ - 'me.areas' => { like => "%,$1,%" } - ]; - } elsif ($search =~ /^ref:(\d+)$/) { - $query->{'-or'} = [ - 'me.external_id' => { like => "%$1%" } - ]; - } else { - $query->{'-or'} = [ - 'me.id' => $search_n, - 'user.email' => { ilike => $like_search }, - 'user.phone' => { ilike => $like_search }, - 'me.external_id' => { ilike => $like_search }, - 'me.name' => { ilike => $like_search }, - 'me.title' => { ilike => $like_search }, - detail => { ilike => $like_search }, - bodies_str => { like => $like_search }, - cobrand_data => { like => $like_search }, - ]; - } - - my $problems = $c->cobrand->problems->search( - $query, - { - prefetch => 'user', - rows => 50, - order_by => $order, - } - )->page( $p_page ); - - $c->stash->{problems} = [ $problems->all ]; - $c->stash->{problems_pager} = $problems->pager; - - if ($valid_email) { - $query = [ - 'user.email' => { ilike => $like_search }, - ]; - } elsif ($valid_phone) { - $query = [ - 'user.phone' => { ilike => $like_search }, - ]; - } elsif ($search =~ /^id:(\d+)$/) { - $query = [ - 'me.id' => int($1), - 'me.problem_id' => int($1), - ]; - } elsif ($search =~ /^area:(\d+)$/) { - $query = []; - } else { - $query = [ - 'me.id' => $search_n, - 'problem.id' => $search_n, - 'user.email' => { ilike => $like_search }, - 'user.phone' => { ilike => $like_search }, - 'me.name' => { ilike => $like_search }, - text => { ilike => $like_search }, - 'me.cobrand_data' => { ilike => $like_search }, - ]; - } - - if (@$query) { - my $updates = $c->cobrand->updates->search( - { - -or => $query, - }, - { - -select => [ 'me.*', qw/problem.bodies_str problem.state/ ], - prefetch => [qw/user problem/], - rows => 50, - order_by => { -desc => 'me.id' } - } - )->page( $u_page ); - $c->stash->{updates} = [ $updates->all ]; - $c->stash->{updates_pager} = $updates->pager; - } - - } else { - - my $problems = $c->cobrand->problems->search( - $query, - { order_by => $order, rows => 50 } - )->page( $p_page ); - $c->stash->{problems} = [ $problems->all ]; - $c->stash->{problems_pager} = $problems->pager; - } -} - sub update_user : Private { my ($self, $c, $object) = @_; my $parsed = FixMyStreet::SMS->parse_username($c->get_param('username')); @@ -378,482 +216,6 @@ sub update_user : Private { return 0; } -sub report_edit_display : Private { - my ( $self, $c ) = @_; - - my $problem = $c->stash->{problem}; - - $c->stash->{page} = 'admin'; - FixMyStreet::Map::display_map( - $c, - latitude => $problem->latitude, - longitude => $problem->longitude, - pins => $problem->used_map - ? [ { - latitude => $problem->latitude, - longitude => $problem->longitude, - colour => $c->cobrand->pin_colour($problem, 'admin'), - type => 'big', - draggable => 1, - } ] - : [], - print_report => 1, - ); -} - -sub report_edit : Path('report_edit') : Args(1) { - my ( $self, $c, $id ) = @_; - - my $problem = $c->cobrand->problems->search( { id => $id } )->first; - - $c->detach( '/page_error_404_not_found', [] ) - unless $problem; - - unless ( - $c->cobrand->moniker eq 'zurich' - || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids) - ) { - $c->detach( '/page_error_403_access_denied', [] ); - } - - $c->stash->{problem} = $problem; - if ( $problem->extra ) { - my @fields; - if ( my $fields = $problem->get_extra_fields ) { - for my $field ( @{$fields} ) { - my $name = $field->{description} ? - "$field->{description} ($field->{name})" : - "$field->{name}"; - push @fields, { name => $name, val => $field->{value} }; - } - } - my $extra = $problem->get_extra_metadata; - if ( $extra->{duplicates} ) { - push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) }; - delete $extra->{duplicates}; - } - for my $key ( keys %$extra ) { - push @fields, { name => $key, val => $extra->{$key} }; - } - - $c->stash->{extra_fields} = \@fields; - } - - $c->forward('/auth/get_csrf_token'); - - $c->forward('categories_for_point'); - - $c->forward('alerts_for_report'); - - $c->forward('check_username_for_abuse', [ $problem->user ] ); - - $c->stash->{updates} = - [ $c->model('DB::Comment') - ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } ) - ->all ]; - - if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { - $self->rotate_photo($c, $problem, @$rotate_photo_param); - $c->detach('report_edit_display'); - } - - if ( $c->cobrand->moniker eq 'zurich' ) { - my $done = $c->cobrand->admin_report_edit(); - $c->detach('report_edit_display') if $done; - } - - if ( $c->get_param('resend') && !$c->cobrand->call_hook('disable_resend') ) { - $c->forward('/auth/check_csrf_token'); - - $problem->resend; - $problem->update(); - $c->stash->{status_message} = _('That problem will now be resent.'); - - $c->forward( 'log_edit', [ $id, 'problem', 'resend' ] ); - } - elsif ( $c->get_param('mark_sent') ) { - $c->forward('/auth/check_csrf_token'); - $problem->update({ whensent => \'current_timestamp' })->discard_changes; - $c->stash->{status_message} = _('That problem has been marked as sent.'); - $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] ); - } - elsif ( $c->get_param('flaguser') ) { - $c->forward('users/flag'); - $c->stash->{problem}->discard_changes; - } - elsif ( $c->get_param('removeuserflag') ) { - $c->forward('users/flag_remove'); - $c->stash->{problem}->discard_changes; - } - elsif ( $c->get_param('banuser') ) { - $c->forward('users/ban'); - } - elsif ( $c->get_param('submit') ) { - $c->forward('/auth/check_csrf_token'); - - my $old_state = $problem->state; - - my %columns = ( - flagged => $c->get_param('flagged') ? 1 : 0, - non_public => $c->get_param('non_public') ? 1 : 0, - ); - foreach (qw/state anonymous title detail name external_id external_body external_team/) { - $columns{$_} = $c->get_param($_); - } - $problem->set_inflated_columns(\%columns); - - if ($c->get_param('closed_updates')) { - $problem->set_extra_metadata(closed_updates => 1); - } else { - $problem->unset_extra_metadata('closed_updates'); - } - - $c->forward( '/admin/report_edit_category', [ $problem, $problem->state ne $old_state ] ); - $c->forward('update_user', [ $problem ]); - - # Deal with photos - my $remove_photo_param = $self->_get_remove_photo_param($c); - if ($remove_photo_param) { - $self->remove_photo($c, $problem, $remove_photo_param); - } - - if ($problem->state eq 'hidden' || $problem->non_public) { - $problem->get_photoset->delete_cached(plus_updates => 1); - } - - if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) { - $problem->confirmed( \'current_timestamp' ); - } - - $problem->lastupdate( \'current_timestamp' ); - $problem->update; - - if ( $problem->state ne $old_state ) { - $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] ); - - my $name = $c->user->moderating_user_name; - my $extra = { is_superuser => 1 }; - if ($c->user->from_body) { - delete $extra->{is_superuser}; - $extra->{is_body_user} = $c->user->from_body->id; - } - my $timestamp = \'current_timestamp'; - $problem->add_to_comments( { - text => $c->stash->{update_text} || '', - created => $timestamp, - confirmed => $timestamp, - user_id => $c->user->id, - name => $name, - mark_fixed => 0, - anonymous => 0, - state => 'confirmed', - problem_state => $problem->state, - extra => $extra - } ); - } - $c->forward( 'log_edit', [ $id, 'problem', 'edit' ] ); - - $c->stash->{status_message} = _('Updated!'); - - # do this here otherwise lastupdate and confirmed times - # do not display correctly - $problem->discard_changes; - } - - $c->detach('report_edit_display'); -} - -=head2 report_edit_category - -Handles changing a problem's category and the complexity that comes with it. -Returns 1 if category changed, 0 if no change. - -=cut - -sub report_edit_category : Private { - my ($self, $c, $problem, $no_comment) = @_; - - if ((my $category = $c->get_param('category')) ne $problem->category) { - my $disable_resend = $c->cobrand->call_hook('disable_resend'); - my $category_old = $problem->category; - $problem->category($category); - my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}}; - my @new_body_ids = map { $_->body_id } @contacts; - # If the report has changed bodies (and not to a subset!) we need to resend it - my %old_map = map { $_ => 1 } @{$problem->bodies_str_ids}; - if (!$disable_resend && grep !$old_map{$_}, @new_body_ids) { - $problem->resend; - } - # If the send methods of the old/new contacts differ we need to resend the report - my @new_send_methods = uniq map { - ( $_->body->can_be_devolved && $_->send_method ) ? - $_->send_method : $_->body->send_method - ? $_->body->send_method - : $c->cobrand->_fallback_body_sender()->{method}; - } @contacts; - my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); - if (!$disable_resend && grep !$old_send_methods{$_}, @new_send_methods) { - $problem->resend; - } - - $problem->bodies_str(join( ',', @new_body_ids )); - my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*'; - if ($no_comment) { - $c->stash->{update_text} = $update_text; - } else { - $problem->add_to_comments({ - text => $update_text, - created => \'current_timestamp', - confirmed => \'current_timestamp', - user_id => $c->user->id, - name => $c->user->from_body ? $c->user->from_body->name : $c->user->name, - state => 'confirmed', - mark_fixed => 0, - anonymous => 0, - }); - } - return 1; - } - return 0; -} - -=head2 report_edit_location - -Handles changing a problem's location and the complexity that comes with it. -For now, we reject the new location if the new location and old locations aren't -covered by the same body. - -Returns 2 if the new position (if any) is acceptable and changed, -1 if acceptable and unchanged, undef otherwise. - -NB: This must be called before report_edit_category, as that might modify -$problem->bodies_str. - -=cut - -sub report_edit_location : Private { - my ($self, $c, $problem) = @_; - - return 1 unless $c->forward('/location/determine_location_from_coords'); - - my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude; - if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) { - # The two actions below change the stash, setting things up for e.g. a - # new report. But here we're only doing it in order to check the found - # bodies match; we don't want to overwrite the existing report data if - # this lookup is bad. So let's save the stash and restore it after the - # comparison. - my $safe_stash = { %{$c->stash} }; - $c->stash->{fetch_all_areas} = 1; - $c->stash->{area_check_action} = 'admin'; - $c->forward('/council/load_and_check_areas', []); - $c->forward('/report/new/setup_categories_and_bodies'); - my %allowed_bodies = map { $_ => 1 } @{$problem->bodies_str_ids}; - my @new_bodies = keys %{$c->stash->{bodies_to_list}}; - my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies; - $c->stash($safe_stash); - return unless $bodies_match; - $problem->latitude($c->stash->{latitude}); - $problem->longitude($c->stash->{longitude}); - my $areas = $c->stash->{all_areas_mapit}; - $problem->areas( ',' . join( ',', sort keys %$areas ) . ',' ); - return 2; - } - return 1; -} - -sub categories_for_point : Private { - my ($self, $c) = @_; - - $c->stash->{report} = $c->stash->{problem}; - # We have a report, stash its location - $c->forward('/report/new/determine_location_from_report'); - # Look up the areas for this location - my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ]; - $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]); - # As with a new report, fetch the bodies/categories - $c->forward('/report/new/setup_categories_and_bodies'); - - # Remove the "Pick a category" option - shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}}; - - $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} }; - - $c->forward('/admin/triage/setup_categories'); - -} - -sub alerts_for_report : Private { - my ($self, $c) = @_; - - $c->stash->{alert_count} = $c->model('DB::Alert')->search({ - alert_type => 'new_updates', - parameter => $c->stash->{report}->id, - confirmed => 1, - whendisabled => undef, - })->count(); -} - -sub templates : Path('templates') : Args(0) { - my ( $self, $c ) = @_; - - my $user = $c->user; - - if ($user->is_superuser) { - $c->forward('fetch_all_bodies'); - $c->stash->{template} = 'admin/templates_index.html'; - } elsif ( $user->from_body ) { - $c->forward('load_template_body', [ $user->from_body->id ]); - $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) ); - } else { - $c->detach( '/page_error_404_not_found', [] ); - } -} - -sub templates_view : Path('templates') : Args(1) { - my ($self, $c, $body_id) = @_; - - $c->forward('load_template_body', [ $body_id ]); - - my @templates = $c->stash->{body}->response_templates->search( - undef, - { - order_by => 'title' - } - ); - - $c->stash->{response_templates} = \@templates; - - $c->stash->{template} = 'admin/templates.html'; -} - -sub template_edit : Path('templates') : Args(2) { - my ( $self, $c, $body_id, $template_id ) = @_; - - $c->forward('load_template_body', [ $body_id ]); - - my $template; - if ($template_id eq 'new') { - $template = $c->stash->{body}->response_templates->new({}); - } - else { - $template = $c->stash->{body}->response_templates->find( $template_id ) - or $c->detach( '/page_error_404_not_found', [] ); - } - - $c->forward('fetch_contacts'); - my @contacts = $template->contacts->all; - my @live_contacts = $c->stash->{live_contacts}->all; - my %active_contacts = map { $_->id => 1 } @contacts; - my @all_contacts = map { { - id => $_->id, - category => $_->category_display, - active => $active_contacts{$_->id}, - email => $_->email, - } } @live_contacts; - $c->stash->{contacts} = \@all_contacts; - - # bare block to use 'last' if form is invalid. - if ($c->req->method eq 'POST') { { - if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) { - $template->contact_response_templates->delete_all; - $template->delete; - } else { - my @live_contact_ids = map { $_->id } @live_contacts; - my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; - my %new_contacts = map { $_ => 1 } @new_contact_ids; - for my $contact (@all_contacts) { - $contact->{active} = $new_contacts{$contact->{id}}; - } - - $template->title( $c->get_param('title') ); - my $query = { title => $template->title }; - if ($template->in_storage) { - $query->{id} = { '!=', $template->id }; - } - if ($c->stash->{body}->response_templates->search($query)->count) { - $c->stash->{errors} ||= {}; - $c->stash->{errors}->{title} = _("There is already a template with that title."); - } - - $template->text( $c->get_param('text') ); - $template->state( $c->get_param('state') ); - $template->external_status_code( $c->get_param('external_status_code') ); - - if ( $template->state && $template->external_status_code ) { - $c->stash->{errors} ||= {}; - $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously."); - $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously."); - } - - $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 ); - if ($template->auto_response) { - my @check_contact_ids = @new_contact_ids; - # If the new template has not specific categories (i.e. it - # applies to all categories) then we need to check each of those - # category ids for existing auto-response templates. - if (!scalar @check_contact_ids) { - @check_contact_ids = @live_contact_ids; - } - my $query = { - 'auto_response' => 1, - 'contact.id' => [ @check_contact_ids, undef ], - -or => { - $template->state ? ('me.state' => $template->state) : (), - $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (), - }, - }; - if ($template->in_storage) { - $query->{'me.id'} = { '!=', $template->id }; - } - if ($c->stash->{body}->response_templates->search($query, { - join => { 'contact_response_templates' => 'contact' }, - })->count) { - $c->stash->{errors} ||= {}; - $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state."); - } - } - - last if $c->stash->{errors}; - - $template->update_or_insert; - $template->contact_response_templates->search({ - contact_id => { -not_in => \@new_contact_ids }, - })->delete; - foreach my $contact_id (@new_contact_ids) { - $template->contact_response_templates->find_or_create({ - contact_id => $contact_id, - }); - } - } - - $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) ); - } } - - $c->stash->{response_template} = $template; - - $c->stash->{template} = 'admin/template_edit.html'; -} - -sub load_template_body : Private { - my ($self, $c, $body_id) = @_; - - my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich'; - my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id); - - unless ( $zurich_user || $has_permission ) { - $c->detach( '/page_error_404_not_found', [] ); - } - - # Regular users can only view their own body's templates - if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) { - $c->res->redirect( $c->uri_for( 'templates', $c->user->from_body->id ) ); - } - - $c->stash->{body} = $c->model('DB::Body')->find($body_id) - or $c->detach( '/page_error_404_not_found', [] ); -} - sub update_edit : Path('update_edit') : Args(1) { my ( $self, $c, $id ) = @_; @@ -866,8 +228,8 @@ sub update_edit : Path('update_edit') : Args(1) { $c->stash->{update} = $update; - if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) { - $self->rotate_photo($c, $update, @$rotate_photo_param); + if (my $rotate_photo_param = $c->forward('_get_rotate_photo_param')) { + $c->forward('rotate_photo', [ $update, @$rotate_photo_param ]); return 1; } @@ -901,9 +263,9 @@ sub update_edit : Path('update_edit') : Args(1) { $edited = 1; } - my $remove_photo_param = $self->_get_remove_photo_param($c); + my $remove_photo_param = $c->forward('_get_remove_photo_param'); if ($remove_photo_param) { - $self->remove_photo($c, $update, $remove_photo_param); + $c->forward('remove_photo', [$update, $remove_photo_param]); } $c->stash->{status_message} = _('Updated!'); @@ -1068,7 +430,7 @@ Rotate a photo 90 degrees left or right =cut # returns index of photo to rotate, if any -sub _get_rotate_photo_param { +sub _get_rotate_photo_param : Private { my ($self, $c) = @_; my $key = first { /^rotate_photo/ } keys %{ $c->req->params } or return; my ($index) = $key =~ /(\d+)$/; @@ -1098,7 +460,7 @@ Remove a photo from a report =cut # Returns index of photo(s) to remove, if any -sub _get_remove_photo_param { +sub _get_remove_photo_param : Private { my ($self, $c) = @_; return 'ALL' if $c->get_param('remove_photo'); diff --git a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm index 098c29ad4..ea03b146f 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm @@ -280,7 +280,6 @@ sub update_contact : Private { $contact->unset_extra_metadata( 'group' ); } - $c->forward('/admin/update_extra_fields', [ $contact ]); $c->forward('contact_cobrand_extra_fields', [ $contact, \%errors ]); @@ -306,12 +305,13 @@ sub update_contact : Private { $c->stash->{errors} = \%errors; } elsif ( $contact->in_storage ) { $c->stash->{updated} = _('Values updated'); - + $c->forward('/admin/log_edit', [ $contact->id, 'category', 'edit' ]); # NB: History is automatically stored by a trigger in the database $contact->update; } else { $c->stash->{updated} = _('New category contact added'); $contact->insert; + $c->forward('/admin/log_edit', [ $contact->id, 'category', 'add' ]); } unless ( %errors ) { @@ -346,6 +346,7 @@ sub confirm_contacts : Private { } ); + $c->forward('/admin/log_edit', [ $c->stash->{body_id}, 'body', 'edit' ]); $c->stash->{updated} = _('Values updated'); } @@ -360,8 +361,10 @@ sub update_body : Private { if ($body) { $body->update( $values->{params} ); + $c->forward('/admin/log_edit', [ $body->id, 'body', 'edit' ]); } else { $body = $c->model('DB::Body')->create( $values->{params} ); + $c->forward('/admin/log_edit', [ $body->id, 'body', 'add' ]); } if ($values->{extras}) { diff --git a/perllib/FixMyStreet/App/Controller/Admin/Reports.pm b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm new file mode 100644 index 000000000..bee2ed498 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm @@ -0,0 +1,516 @@ +package FixMyStreet::App::Controller::Admin::Reports; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use List::MoreUtils 'uniq'; +use FixMyStreet::SMS; +use Utils; + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Reports - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages + +=head1 METHODS + +=cut + +sub index : Path { + my ( $self, $c ) = @_; + + $c->stash->{edit_body_contacts} = 1 + if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}}; + + my $query = {}; + if ( $c->cobrand->moniker eq 'zurich' ) { + my $type = $c->stash->{admin_type}; + my $body = $c->stash->{body}; + if ( $type eq 'dm' ) { + my @children = map { $_->id } $body->bodies->all; + my @all = (@children, $body->id); + $query = { bodies_str => \@all }; + } elsif ( $type eq 'sdm' ) { + $query = { bodies_str => $body->id }; + } + } + + my $order = $c->get_param('o') || 'id'; + my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1; + $c->stash->{order} = $order; + $c->stash->{dir} = $dir; + $order = $dir ? { -desc => "me.$order" } : "me.$order"; + + my $p_page = $c->get_param('p') || 1; + my $u_page = $c->get_param('u') || 1; + + return if $c->cobrand->call_hook(report_search_query => $query, $p_page, $u_page, $order); + + if (my $search = $c->get_param('search')) { + $search = $self->trim($search); + + # In case an email address, wrapped in <...> + if ($search =~ /^<(.*)>$/) { + my $possible_email = $1; + my $parsed = FixMyStreet::SMS->parse_username($possible_email); + $search = $possible_email if $parsed->{email}; + } + + $c->stash->{searched} = $search; + + my $search_n = 0; + $search_n = int($search) if $search =~ /^\d+$/; + + my $like_search = "%$search%"; + + my $parsed = FixMyStreet::SMS->parse_username($search); + my $valid_phone = $parsed->{phone}; + my $valid_email = $parsed->{email}; + + if ($valid_email) { + $query->{'-or'} = [ + 'user.email' => { ilike => $like_search }, + ]; + } elsif ($valid_phone) { + $query->{'-or'} = [ + 'user.phone' => { ilike => $like_search }, + ]; + } elsif ($search =~ /^id:(\d+)$/) { + $query->{'-or'} = [ + 'me.id' => int($1), + ]; + } elsif ($search =~ /^area:(\d+)$/) { + $query->{'-or'} = [ + 'me.areas' => { like => "%,$1,%" } + ]; + } elsif ($search =~ /^ref:(\d+)$/) { + $query->{'-or'} = [ + 'me.external_id' => { like => "%$1%" } + ]; + } else { + $query->{'-or'} = [ + 'me.id' => $search_n, + 'user.email' => { ilike => $like_search }, + 'user.phone' => { ilike => $like_search }, + 'me.external_id' => { ilike => $like_search }, + 'me.name' => { ilike => $like_search }, + 'me.title' => { ilike => $like_search }, + detail => { ilike => $like_search }, + bodies_str => { like => $like_search }, + cobrand_data => { like => $like_search }, + ]; + } + + my $problems = $c->cobrand->problems->search( + $query, + { + prefetch => 'user', + rows => 50, + order_by => $order, + } + )->page( $p_page ); + + $c->stash->{problems} = [ $problems->all ]; + $c->stash->{problems_pager} = $problems->pager; + + if ($valid_email) { + $query = [ + 'user.email' => { ilike => $like_search }, + ]; + } elsif ($valid_phone) { + $query = [ + 'user.phone' => { ilike => $like_search }, + ]; + } elsif ($search =~ /^id:(\d+)$/) { + $query = [ + 'me.id' => int($1), + 'me.problem_id' => int($1), + ]; + } elsif ($search =~ /^area:(\d+)$/) { + $query = []; + } else { + $query = [ + 'me.id' => $search_n, + 'problem.id' => $search_n, + 'user.email' => { ilike => $like_search }, + 'user.phone' => { ilike => $like_search }, + 'me.name' => { ilike => $like_search }, + text => { ilike => $like_search }, + 'me.cobrand_data' => { ilike => $like_search }, + ]; + } + + if (@$query) { + my $updates = $c->cobrand->updates->search( + { + -or => $query, + }, + { + -select => [ 'me.*', qw/problem.bodies_str problem.state/ ], + prefetch => [qw/user problem/], + rows => 50, + order_by => { -desc => 'me.id' } + } + )->page( $u_page ); + $c->stash->{updates} = [ $updates->all ]; + $c->stash->{updates_pager} = $updates->pager; + } + + } else { + + my $problems = $c->cobrand->problems->search( + $query, + { order_by => $order, rows => 50 } + )->page( $p_page ); + $c->stash->{problems} = [ $problems->all ]; + $c->stash->{problems_pager} = $problems->pager; + } +} + +sub edit_display : Private { + my ( $self, $c ) = @_; + + my $problem = $c->stash->{problem}; + + $c->stash->{page} = 'admin'; + FixMyStreet::Map::display_map( + $c, + latitude => $problem->latitude, + longitude => $problem->longitude, + pins => $problem->used_map + ? [ { + latitude => $problem->latitude, + longitude => $problem->longitude, + colour => $c->cobrand->pin_colour($problem, 'admin'), + type => 'big', + draggable => 1, + } ] + : [], + print_report => 1, + ); +} + +sub edit : Path('/admin/report_edit') : Args(1) { + my ( $self, $c, $id ) = @_; + + my $problem = $c->cobrand->problems->search( { id => $id } )->first; + + $c->detach( '/page_error_404_not_found', [] ) + unless $problem; + + unless ( + $c->cobrand->moniker eq 'zurich' + || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids) + ) { + $c->detach( '/page_error_403_access_denied', [] ); + } + + $c->stash->{problem} = $problem; + if ( $problem->extra ) { + my @fields; + if ( my $fields = $problem->get_extra_fields ) { + for my $field ( @{$fields} ) { + my $name = $field->{description} ? + "$field->{description} ($field->{name})" : + "$field->{name}"; + push @fields, { name => $name, val => $field->{value} }; + } + } + my $extra = $problem->get_extra_metadata; + if ( $extra->{duplicates} ) { + push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) }; + delete $extra->{duplicates}; + } + for my $key ( keys %$extra ) { + push @fields, { name => $key, val => $extra->{$key} }; + } + + $c->stash->{extra_fields} = \@fields; + } + + $c->forward('/auth/get_csrf_token'); + + $c->forward('categories_for_point'); + + $c->forward('alerts_for_report'); + + $c->forward('/admin/check_username_for_abuse', [ $problem->user ] ); + + $c->stash->{updates} = + [ $c->model('DB::Comment') + ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } ) + ->all ]; + + if (my $rotate_photo_param = $c->forward('/admin/_get_rotate_photo_param')) { + $c->forward('/admin/rotate_photo', [$problem, @$rotate_photo_param]); + $c->detach('edit_display'); + } + + if ( $c->cobrand->moniker eq 'zurich' ) { + my $done = $c->cobrand->admin_report_edit(); + $c->detach('edit_display') if $done; + } + + if ( $c->get_param('resend') && !$c->cobrand->call_hook('disable_resend') ) { + $c->forward('/auth/check_csrf_token'); + + $problem->resend; + $problem->update(); + $c->stash->{status_message} = _('That problem will now be resent.'); + + $c->forward( '/admin/log_edit', [ $id, 'problem', 'resend' ] ); + } + elsif ( $c->get_param('mark_sent') ) { + $c->forward('/auth/check_csrf_token'); + $problem->update({ whensent => \'current_timestamp' })->discard_changes; + $c->stash->{status_message} = _('That problem has been marked as sent.'); + $c->forward( '/admin/log_edit', [ $id, 'problem', 'marked sent' ] ); + } + elsif ( $c->get_param('flaguser') ) { + $c->forward('/admin/users/flag'); + $c->stash->{problem}->discard_changes; + } + elsif ( $c->get_param('removeuserflag') ) { + $c->forward('/admin/users/flag_remove'); + $c->stash->{problem}->discard_changes; + } + elsif ( $c->get_param('banuser') ) { + $c->forward('/admin/users/ban'); + } + elsif ( $c->get_param('submit') ) { + $c->forward('/auth/check_csrf_token'); + + my $old_state = $problem->state; + + my %columns = ( + flagged => $c->get_param('flagged') ? 1 : 0, + non_public => $c->get_param('non_public') ? 1 : 0, + ); + foreach (qw/state anonymous title detail name external_id external_body external_team/) { + $columns{$_} = $c->get_param($_); + } + + # Look this up here for moderation line to use + my $remove_photo_param = $c->forward('/admin/_get_remove_photo_param'); + + if ($columns{title} ne $problem->title || $columns{detail} ne $problem->detail || + $columns{anonymous} ne $problem->anonymous || $remove_photo_param) { + $problem->create_related( moderation_original_data => { + title => $problem->title, + detail => $problem->detail, + photo => $problem->photo, + anonymous => $problem->anonymous, + category => $problem->category, + $problem->extra ? (extra => $problem->extra) : (), + }); + } + + $problem->set_inflated_columns(\%columns); + + if ($c->get_param('closed_updates')) { + $problem->set_extra_metadata(closed_updates => 1); + } else { + $problem->unset_extra_metadata('closed_updates'); + } + + $c->forward( '/admin/reports/edit_category', [ $problem, $problem->state ne $old_state ] ); + $c->forward('/admin/update_user', [ $problem ]); + + # Deal with photos + if ($remove_photo_param) { + $c->forward('/admin/remove_photo', [ $problem, $remove_photo_param ]); + } + + if ($problem->state eq 'hidden' || $problem->non_public) { + $problem->get_photoset->delete_cached(plus_updates => 1); + } + + if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) { + $problem->confirmed( \'current_timestamp' ); + } + + $problem->lastupdate( \'current_timestamp' ); + $problem->update; + + if ( $problem->state ne $old_state ) { + $c->forward( '/admin/log_edit', [ $id, 'problem', 'state_change' ] ); + + my $name = $c->user->moderating_user_name; + my $extra = { is_superuser => 1 }; + if ($c->user->from_body) { + delete $extra->{is_superuser}; + $extra->{is_body_user} = $c->user->from_body->id; + } + my $timestamp = \'current_timestamp'; + $problem->add_to_comments( { + text => $c->stash->{update_text} || '', + created => $timestamp, + confirmed => $timestamp, + user_id => $c->user->id, + name => $name, + mark_fixed => 0, + anonymous => 0, + state => 'confirmed', + problem_state => $problem->state, + extra => $extra + } ); + } + $c->forward( '/admin/log_edit', [ $id, 'problem', 'edit' ] ); + + $c->stash->{status_message} = _('Updated!'); + + # do this here otherwise lastupdate and confirmed times + # do not display correctly + $problem->discard_changes; + } + + $c->detach('edit_display'); +} + +=head2 edit_category + +Handles changing a problem's category and the complexity that comes with it. +Returns 1 if category changed, 0 if no change. + +=cut + +sub edit_category : Private { + my ($self, $c, $problem, $no_comment) = @_; + + if ((my $category = $c->get_param('category')) ne $problem->category) { + my $disable_resend = $c->cobrand->call_hook('disable_resend'); + my $category_old = $problem->category; + $problem->category($category); + my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}}; + my @new_body_ids = map { $_->body_id } @contacts; + # If the report has changed bodies (and not to a subset!) we need to resend it + my %old_map = map { $_ => 1 } @{$problem->bodies_str_ids}; + if (!$disable_resend && grep !$old_map{$_}, @new_body_ids) { + $problem->resend; + } + # If the send methods of the old/new contacts differ we need to resend the report + my @new_send_methods = uniq map { + ( $_->body->can_be_devolved && $_->send_method ) ? + $_->send_method : $_->body->send_method + ? $_->body->send_method + : $c->cobrand->_fallback_body_sender()->{method}; + } @contacts; + my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email"); + if (!$disable_resend && grep !$old_send_methods{$_}, @new_send_methods) { + $problem->resend; + } + + $problem->bodies_str(join( ',', @new_body_ids )); + my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*'; + if ($no_comment) { + $c->stash->{update_text} = $update_text; + } else { + $problem->add_to_comments({ + text => $update_text, + created => \'current_timestamp', + confirmed => \'current_timestamp', + user_id => $c->user->id, + name => $c->user->from_body ? $c->user->from_body->name : $c->user->name, + state => 'confirmed', + mark_fixed => 0, + anonymous => 0, + }); + } + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'category_change' ] ); + return 1; + } + return 0; +} + +=head2 edit_location + +Handles changing a problem's location and the complexity that comes with it. +For now, we reject the new location if the new location and old locations aren't +covered by the same body. + +Returns 2 if the new position (if any) is acceptable and changed, +1 if acceptable and unchanged, undef otherwise. + +NB: This must be called before edit_category, as that might modify +$problem->bodies_str. + +=cut + +sub edit_location : Private { + my ($self, $c, $problem) = @_; + + return 1 unless $c->forward('/location/determine_location_from_coords'); + + my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude; + if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) { + # The two actions below change the stash, setting things up for e.g. a + # new report. But here we're only doing it in order to check the found + # bodies match; we don't want to overwrite the existing report data if + # this lookup is bad. So let's save the stash and restore it after the + # comparison. + my $safe_stash = { %{$c->stash} }; + $c->stash->{fetch_all_areas} = 1; + $c->stash->{area_check_action} = 'admin'; + $c->forward('/council/load_and_check_areas', []); + $c->forward('/report/new/setup_categories_and_bodies'); + my %allowed_bodies = map { $_ => 1 } @{$problem->bodies_str_ids}; + my @new_bodies = keys %{$c->stash->{bodies_to_list}}; + my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies; + $c->stash($safe_stash); + return unless $bodies_match; + $problem->latitude($c->stash->{latitude}); + $problem->longitude($c->stash->{longitude}); + my $areas = $c->stash->{all_areas_mapit}; + $problem->areas( ',' . join( ',', sort keys %$areas ) . ',' ); + return 2; + } + return 1; +} + +sub categories_for_point : Private { + my ($self, $c) = @_; + + $c->stash->{report} = $c->stash->{problem}; + # We have a report, stash its location + $c->forward('/report/new/determine_location_from_report'); + # Look up the areas for this location + my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ]; + $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]); + # As with a new report, fetch the bodies/categories + $c->forward('/report/new/setup_categories_and_bodies'); + + # Remove the "Pick a category" option + shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}}; + + $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} }; + + $c->forward('/admin/triage/setup_categories'); + +} + +sub alerts_for_report : Private { + my ($self, $c) = @_; + + $c->stash->{alert_count} = $c->model('DB::Alert')->search({ + alert_type => 'new_updates', + parameter => $c->stash->{report}->id, + confirmed => 1, + whendisabled => undef, + })->count(); +} + +sub trim { + my $self = shift; + my $e = shift; + $e =~ s/^\s+//; + $e =~ s/\s+$//; + return $e; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm index 902ed6255..279ee695c 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm @@ -63,6 +63,7 @@ sub form { if ($c->get_param('delete_role')) { $role->delete; + $c->forward('/admin/log_edit', [ $role->id, 'role', 'delete' ]); $c->response->redirect($c->uri_for($self->action_for('index'))); $c->detach; } @@ -88,11 +89,13 @@ sub form { $opts->{body_id} = $c->user->from_body->id; } + my $action = $role->in_storage ? 'edit' : 'add'; my $form = FixMyStreet::App::Form::Role->new(%$opts); $c->stash(template => 'admin/roles/form.html', form => $form); $form->process(item => $role, params => $c->req->params); return unless $form->validated; + $c->forward('/admin/log_edit', [ $role->id, 'role', $action ]); $c->response->redirect($c->uri_for($self->action_for('index'))); } diff --git a/perllib/FixMyStreet/App/Controller/Admin/Templates.pm b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm new file mode 100644 index 000000000..97a62c2b8 --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm @@ -0,0 +1,179 @@ +package FixMyStreet::App::Controller::Admin::Templates; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +=head1 NAME + +FixMyStreet::App::Controller::Admin::Templates - Catalyst Controller + +=head1 DESCRIPTION + +Admin pages for response templates + +=head1 METHODS + +=cut + +sub index : Path : Args(0) { + my ( $self, $c ) = @_; + + my $user = $c->user; + + if ($user->is_superuser) { + $c->forward('/admin/fetch_all_bodies'); + } elsif ( $user->from_body ) { + $c->forward('load_template_body', [ $user->from_body->id ]); + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) ); + } else { + $c->detach( '/page_error_404_not_found', [] ); + } +} + +sub view : Path : Args(1) { + my ($self, $c, $body_id) = @_; + + $c->forward('load_template_body', [ $body_id ]); + + my @templates = $c->stash->{body}->response_templates->search( + undef, + { + order_by => 'title' + } + ); + + $c->stash->{response_templates} = \@templates; +} + +sub edit : Path : Args(2) { + my ( $self, $c, $body_id, $template_id ) = @_; + + $c->forward('load_template_body', [ $body_id ]); + + my $template; + if ($template_id eq 'new') { + $template = $c->stash->{body}->response_templates->new({}); + } + else { + $template = $c->stash->{body}->response_templates->find( $template_id ) + or $c->detach( '/page_error_404_not_found', [] ); + } + + $c->forward('/admin/fetch_contacts'); + my @contacts = $template->contacts->all; + my @live_contacts = $c->stash->{live_contacts}->all; + my %active_contacts = map { $_->id => 1 } @contacts; + my @all_contacts = map { { + id => $_->id, + category => $_->category_display, + active => $active_contacts{$_->id}, + email => $_->email, + } } @live_contacts; + $c->stash->{contacts} = \@all_contacts; + + # bare block to use 'last' if form is invalid. + if ($c->req->method eq 'POST') { { + if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) { + $template->contact_response_templates->delete_all; + $template->delete; + $c->forward('/admin/log_edit', [ $template->id, 'template', 'delete' ]); + } else { + my @live_contact_ids = map { $_->id } @live_contacts; + my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids; + my %new_contacts = map { $_ => 1 } @new_contact_ids; + for my $contact (@all_contacts) { + $contact->{active} = $new_contacts{$contact->{id}}; + } + + $template->title( $c->get_param('title') ); + my $query = { title => $template->title }; + if ($template->in_storage) { + $query->{id} = { '!=', $template->id }; + } + if ($c->stash->{body}->response_templates->search($query)->count) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{title} = _("There is already a template with that title."); + } + + $template->text( $c->get_param('text') ); + $template->state( $c->get_param('state') ); + $template->external_status_code( $c->get_param('external_status_code') ); + + if ( $template->state && $template->external_status_code ) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously."); + $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously."); + } + + $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 ); + if ($template->auto_response) { + my @check_contact_ids = @new_contact_ids; + # If the new template has not specific categories (i.e. it + # applies to all categories) then we need to check each of those + # category ids for existing auto-response templates. + if (!scalar @check_contact_ids) { + @check_contact_ids = @live_contact_ids; + } + my $query = { + 'auto_response' => 1, + 'contact.id' => [ @check_contact_ids, undef ], + -or => { + $template->state ? ('me.state' => $template->state) : (), + $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (), + }, + }; + if ($template->in_storage) { + $query->{'me.id'} = { '!=', $template->id }; + } + if ($c->stash->{body}->response_templates->search($query, { + join => { 'contact_response_templates' => 'contact' }, + })->count) { + $c->stash->{errors} ||= {}; + $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state."); + } + } + + last if $c->stash->{errors}; + + $template->update_or_insert; + $template->contact_response_templates->search({ + contact_id => { -not_in => \@new_contact_ids }, + })->delete; + foreach my $contact_id (@new_contact_ids) { + $template->contact_response_templates->find_or_create({ + contact_id => $contact_id, + }); + } + my $action = $template_id eq 'new' ? 'add' : 'edit'; + $c->forward('/admin/log_edit', [ $template->id, 'template', $action ]); + } + + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) ); + } } + + $c->stash->{response_template} = $template; +} + +sub load_template_body : Private { + my ($self, $c, $body_id) = @_; + + my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich'; + my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id); + + unless ( $zurich_user || $has_permission ) { + $c->detach( '/page_error_404_not_found', [] ); + } + + # Regular users can only view their own body's templates + if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) { + $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->user->from_body->id ) ); + } + + $c->stash->{body} = $c->model('DB::Body')->find($body_id) + or $c->detach( '/page_error_404_not_found', [] ); +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/App/Controller/Admin/Triage.pm b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm index 0eabd340d..428c35073 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Triage.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm @@ -113,7 +113,7 @@ sub update : Private { my $current_category = $problem->category; my $new_category = $c->get_param('category'); - my $changed = $c->forward('/admin/report_edit_category', [ $problem, 1 ] ); + my $changed = $c->forward('/admin/reports/edit_category', [ $problem, 1 ] ); if ( $changed ) { $c->stash->{problem}->update( { state => 'confirmed' } ); diff --git a/perllib/FixMyStreet/App/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm index 0d7c23fff..802fbb9f5 100644 --- a/perllib/FixMyStreet/App/Controller/Admin/Users.pm +++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm @@ -171,7 +171,7 @@ sub add : Local : Args(0) { $c->forward('user_cobrand_extra_fields'); $user->insert; - $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'add' ] ); $c->flash->{status_message} = _("Updated!"); $c->detach('post_edit_redirect', [ $user ]); @@ -189,19 +189,24 @@ sub fetch_body_roles : Private { $c->stash->{roles} = [ $roles->all ]; } -sub edit : Path : Args(1) { +sub user : Chained('/') PathPart('admin/users') : CaptureArgs(1) { my ( $self, $c, $id ) = @_; - $c->forward('/auth/get_csrf_token'); - my $user = $c->cobrand->users->find( { id => $id } ); $c->detach( '/page_error_404_not_found', [] ) unless $user; + $c->stash->{user} = $user; unless ( $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) { $c->detach('/page_error_403_access_denied', []); } +} - $c->stash->{user} = $user; +sub edit : Chained('user') : PathPart('') : Args(0) { + my ( $self, $c ) = @_; + + $c->forward('/auth/get_csrf_token'); + + my $user = $c->stash->{user}; $c->forward( '/admin/check_username_for_abuse', [ $user ] ); if ( $user->from_body && $c->user->has_permission_to('user_manage_permissions', $user->from_body->id) ) { @@ -232,14 +237,12 @@ sub edit : Path : Args(1) { } elsif ( $c->get_param('submit') and $c->get_param('send_login_email') ) { my $email = lc $c->get_param('email'); my %args = ( email => $email ); - $args{user_id} = $id if $user->email ne $email || !$user->email_verified; + $args{user_id} = $user->id if $user->email ne $email || !$user->email_verified; $c->forward('send_login_email', [ \%args ]); } elsif ( $c->get_param('update_alerts') ) { $c->forward('update_alerts'); } elsif ( $c->get_param('submit') ) { - my $edited = 0; - my $name = $c->get_param('name'); my $email = lc $c->get_param('email'); my $phone = $c->get_param('phone'); @@ -281,19 +284,10 @@ sub edit : Path : Args(1) { return if %{$c->stash->{field_errors}}; - if ( ($user->email || "") ne $email || - $user->name ne $name || - ($user->phone || "") ne $phone || - ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) || - (!$user->from_body && $c->get_param('body')) - ) { - $edited = 1; - } - if ($existing_user_cobrand) { $existing_user->adopt($user); - $c->forward( '/admin/log_edit', [ $id, 'user', 'merge' ] ); - return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $existing_user->id ) ); + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'merge' ] ); + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $existing_user->id ] ) ); } $user->email($email) if !$existing_email; @@ -382,9 +376,7 @@ sub edit : Path : Args(1) { } $user->update; - if ($edited) { - $c->forward( '/admin/log_edit', [ $id, 'user', 'edit' ] ); - } + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->flash->{status_message} = _("Updated!"); $c->detach('post_edit_redirect', [ $user ]); @@ -414,13 +406,45 @@ sub edit : Path : Args(1) { return 1; } +sub log : Chained('user') : PathPart('log') : Args(0) { + my ($self, $c) = @_; + + my $user = $c->stash->{user}; + + my $after = $c->get_param('after'); + + my %time; + foreach ($user->admin_logs->all) { + push @{$time{$_->whenedited->epoch}}, { type => 'log', date => $_->whenedited, log => $_ }; + } + foreach ($c->cobrand->problems->search({ extra => { like => '%contributed_by%' . $user->id . '%' } })->all) { + next unless $_->get_extra_metadata('contributed_by') == $user->id; + push @{$time{$_->created->epoch}}, { type => 'problemContributedBy', date => $_->created, obj => $_ }; + } + + foreach ($user->user_planned_reports->all) { + push @{$time{$_->added->epoch}}, { type => 'shortlistAdded', date => $_->added, obj => $_->report }; + push @{$time{$_->removed->epoch}}, { type => 'shortlistRemoved', date => $_->removed, obj => $_->report } if $_->removed; + } + + foreach ($user->problems->all) { + push @{$time{$_->created->epoch}}, { type => 'problem', date => $_->created, obj => $_ }; + } + + foreach ($user->comments->all) { + push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_}; + } + + $c->stash->{time} = \%time; +} + sub post_edit_redirect : Private { my ( $self, $c, $user ) = @_; # User may not be visible on this cobrand, e.g. if their from_body # wasn't set. if ( $c->cobrand->users->find( { id => $user->id } ) ) { - return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) ); + return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $user->id ] ) ); } else { return $c->res->redirect( $c->uri_for_action( 'admin/users/index' ) ); } @@ -605,6 +629,7 @@ sub user_remove_account : Private { my ( $self, $c, $user ) = @_; $c->forward('user_logout_everywhere', [ $user ]); $user->anonymize_account; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('That user’s personal details have been removed.'); } @@ -632,6 +657,7 @@ sub ban : Private { $c->stash->{status_message} = _('User already in abuse list'); } else { $abuse->insert; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User added to abuse list'); } $c->stash->{username_in_abuse} = 1; @@ -642,6 +668,7 @@ sub ban : Private { $c->stash->{status_message} = _('User already in abuse list'); } else { $abuse->insert; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User added to abuse list'); } $c->stash->{username_in_abuse} = 1; @@ -663,6 +690,7 @@ sub unban : Private { my $abuse = $c->model('DB::Abuse')->search({ email => \@username }); if ( $abuse ) { $abuse->delete; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('user removed from abuse list'); } else { $c->stash->{status_message} = _('user not in abuse list'); @@ -692,6 +720,7 @@ sub flag : Private { } else { $user->flagged(1); $user->update; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User flagged'); } @@ -721,6 +750,7 @@ sub flag_remove : Private { } else { $user->flagged(0); $user->update; + $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] ); $c->stash->{status_message} = _('User flag removed'); } diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm index f71698e84..8a9f3b5a2 100644 --- a/perllib/FixMyStreet/App/Controller/Moderate.pm +++ b/perllib/FixMyStreet/App/Controller/Moderate.pm @@ -298,7 +298,7 @@ sub moderate_location : Private { my $problem = $c->stash->{problem}; - my $moved = $c->forward('/admin/report_edit_location', [ $problem ]); + my $moved = $c->forward('/admin/reports/edit_location', [ $problem ]); if (!$moved) { # New lat/lon isn't valid, show an error $c->stash->{moderate_errors} ||= []; @@ -315,11 +315,11 @@ sub moderate_category : Private { return unless $c->get_param('category'); # The admin category editing needs to know all the categories etc - $c->forward('/admin/categories_for_point'); + $c->forward('/admin/reports/categories_for_point'); my $problem = $c->stash->{problem}; - my $changed = $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + my $changed = $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] ); # It might need to set_report_extras in future if ($changed) { return 'category'; diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 89350b1cb..190687d41 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -372,7 +372,7 @@ sub inspect : Private { my $problem = $c->stash->{problem}; my $permissions = $c->stash->{_permissions}; - $c->forward('/admin/categories_for_point'); + $c->forward('/admin/reports/categories_for_point'); $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } }; if ($c->cobrand->can('council_area_id')) { @@ -477,7 +477,7 @@ sub inspect : Private { $problem->get_photoset->delete_cached(plus_updates => 1); } - if ( !$c->forward( '/admin/report_edit_location', [ $problem ] ) ) { + if ( !$c->forward( '/admin/reports/edit_location', [ $problem ] ) ) { # New lat/lon isn't valid, show an error $valid = 0; $c->stash->{errors} ||= []; @@ -485,10 +485,11 @@ sub inspect : Private { } if ($permissions->{report_inspect} || $permissions->{report_edit_category}) { - $c->forward( '/admin/report_edit_category', [ $problem, 1 ] ); + $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] ); if ($c->stash->{update_text}) { - $update_text .= "\n\n" . $c->stash->{update_text}; + $update_text .= "\n\n" if $update_text; + $update_text .= $c->stash->{update_text}; } # The new category might require extra metadata (e.g. pothole size), so @@ -515,6 +516,7 @@ sub inspect : Private { if ($valid) { $problem->lastupdate( \'current_timestamp' ); $problem->update; + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'edit' ] ); if ($update_text || %update_params) { my $timestamp = \'current_timestamp'; if (my $saved_at = $c->get_param('saved_at')) { diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm index 6bbbdc775..1040116ed 100644 --- a/perllib/FixMyStreet/Cobrand/Zurich.pm +++ b/perllib/FixMyStreet/Cobrand/Zurich.pm @@ -364,7 +364,7 @@ sub set_problem_state { my ($self, $c, $problem, $new_state) = @_; return $self->update_admin_log($c, $problem) if $new_state eq $problem->state; $problem->state( $new_state ); - $c->forward( 'log_edit', [ $problem->id, 'problem', "state change to $new_state" ] ); + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', "state change to $new_state" ] ); } =head1 C<update_admin_log> @@ -388,7 +388,7 @@ sub update_admin_log { $text = "Logging time_spent"; } - $c->forward( 'log_edit', [ $problem->id, 'problem', $text, $time_spent ] ); + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', $text, $time_spent ] ); } # Any user with from_body set can view admin @@ -898,7 +898,7 @@ sub admin_report_edit { $self->set_problem_state($c, $problem, 'confirmed'); } $problem->update; - $c->forward( 'log_edit', [ $problem->id, 'problem', + $c->forward( '/admin/log_edit', [ $problem->id, 'problem', $not_contactable ? _('Customer not contactable') : _('Sent report back') ] ); diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm index 221690405..5564d829a 100644 --- a/perllib/FixMyStreet/DB/Result/AdminLog.pm +++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm @@ -61,4 +61,79 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BLPP1KitphuY56ptaXhzgg +sub link { + my $self = shift; + + my $type = $self->object_type; + my $id = $self->object_id; + return "/report/$id" if $type eq 'problem'; + return "/admin/users/$id" if $type eq 'user'; + return "/admin/body/$id" if $type eq 'body'; + return "/admin/roles/$id" if $type eq 'role'; + if ($type eq 'update') { + my $update = $self->object; + return "/report/" . $update->problem_id . "#update_$id"; + } + if ($type eq 'moderation') { + my $mod = $self->object; + if ($mod->comment_id) { + my $update = $self->result_source->schema->resultset('Comment')->find($mod->comment_id); + return "/report/" . $update->problem_id . "#update_" . $mod->comment_id; + } else { + return "/report/" . $mod->problem_id; + } + } + if ($type eq 'template') { + my $template = $self->object; + return "/admin/templates/" . $template->body_id . "/$id"; + } + if ($type eq 'category') { + my $category = $self->object; + return "/admin/body/" . $category->body_id . '/' . $category->category; + } + return ''; +} + +sub actual_object_type { + my $self = shift; + my $type = $self->object_type; + return $type unless $type eq 'moderation' && $self->object; + return $self->object->comment_id ? 'update' : 'report'; +} + +sub object_summary { + my $self = shift; + my $object = $self->object; + return unless $object; + + return $object->comment_id || $object->problem_id if $self->object_type eq 'moderation'; + return $object->email || $object->phone || $object->id if $self->object_type eq 'user'; + + my $type_to_thing = { + body => 'name', + role => 'name', + template => 'title', + category => 'category', + }; + my $thing = $type_to_thing->{$self->object_type} || 'id'; + + return $object->$thing; +} + +sub object { + my $self = shift; + + my $type = $self->object_type; + my $id = $self->object_id; + my $type_to_object = { + moderation => 'ModerationOriginalData', + template => 'ResponseTemplate', + category => 'Contact', + update => 'Comment', + }; + $type = $type_to_object->{$type} || ucfirst $type; + my $object = $self->result_source->schema->resultset($type)->find($id); + return $object; +} + 1; diff --git a/perllib/FixMyStreet/Template.pm b/perllib/FixMyStreet/Template.pm index 9c565114b..ba4376959 100644 --- a/perllib/FixMyStreet/Template.pm +++ b/perllib/FixMyStreet/Template.pm @@ -113,7 +113,7 @@ into <br>s too. sub html_paragraph : Filter('html_para') { my $text = shift; - my @paras = split(/(?:\r?\n){2,}/, $text); + my @paras = grep { $_ } split(/(?:\r?\n){2,}/, $text); s/\r?\n/<br>\n/g for @paras; $text = "<p>\n" . join("\n</p>\n\n<p>\n", @paras) . "</p>\n"; return $text; diff --git a/t/app/controller/admin/bodies.t b/t/app/controller/admin/bodies.t index cd86e3da6..aaa9bca65 100644 --- a/t/app/controller/admin/bodies.t +++ b/t/app/controller/admin/bodies.t @@ -327,4 +327,11 @@ FixMyStreet::override_config { }; }; +subtest 'check log of the above' => sub { + $mech->get_ok('/admin/users/' . $superuser->id . '/log'); + $mech->content_contains('Added category <a href="/admin/body/' . $body->id . '/test/category">test/category</a>'); + $mech->content_contains('Edited category <a href="/admin/body/' . $body->id . '/test category">test category</a>'); + $mech->content_contains('Edited body <a href="/admin/body/' . $body->id . '">Aberdeen City Council</a>'); +}; + done_testing(); diff --git a/t/app/controller/admin/report_edit.t b/t/app/controller/admin/report_edit.t index c6e03ff7e..85f83781c 100644 --- a/t/app/controller/admin/report_edit.t +++ b/t/app/controller/admin/report_edit.t @@ -353,7 +353,7 @@ foreach my $test ( user_body => $oxfordshire, changes => { state => 'in progress', category => 'Potholes' }, log_entries => [ - qw/edit state_change edit state_change edit edit resend edit state_change edit state_change edit state_change edit state_change edit state_change edit edit edit edit edit/ + qw/edit state_change category_change edit state_change edit edit resend edit state_change edit state_change edit state_change edit state_change edit state_change edit edit edit edit edit/ ], resend => 0, }, @@ -412,6 +412,13 @@ foreach my $test ( delete $test->{changes}->{closed_updates}; } + if ($test->{changes}{title} || $test->{changes}{detail} || $test->{changes}{anonymous}) { + $mech->get_ok("/report/$report_id"); + $mech->content_contains("Anonymous: <del style='background-color:#fcc'>No</del><ins style='background-color:#cfc'>Yes</ins>") if $test->{changes}{anonymous}; + $mech->content_contains("Details: <ins style='background-color:#cfc'>Edited </ins>Detail<del style='background-color:#fcc'> for Report to Edit</del>") if $test->{changes}{detail}; + $mech->content_contains("Subject: <ins style='background-color:#cfc'>Edited </ins>Repor<del style='background-color:#fcc'>t to Edi</del>") if $test->{changes}{title}; + } + is $report->$_, $test->{changes}->{$_}, "$_ updated" for grep { $_ ne 'username' } keys %{ $test->{changes} }; if ( $test->{user} ) { diff --git a/t/app/controller/admin/roles.t b/t/app/controller/admin/roles.t index 6dd40cbb6..bc8371404 100644 --- a/t/app/controller/admin/roles.t +++ b/t/app/controller/admin/roles.t @@ -22,7 +22,7 @@ $user->user_body_permissions->create({ permission_type => 'report_edit_priority', }); -FixMyStreet::DB->resultset("Role")->create({ +my $role_a = FixMyStreet::DB->resultset("Role")->create({ body => $body, name => 'Role A', permissions => ['moderate', 'user_edit'], @@ -129,4 +129,11 @@ subtest 'superuser can see all bodies' => sub { $mech->content_contains('Role C'); }; +subtest 'check log of the above' => sub { + my $id = FixMyStreet::DB->resultset("Role")->find({ name => "Role B" })->id; + $mech->get_ok('/admin/users/' . $editor->id . '/log'); + $mech->content_contains('Added role <a href="/admin/roles/' . $id . '">Role B</a>'); + $mech->content_contains('Deleted role ' . $role_a->id); +}; + done_testing(); diff --git a/t/app/controller/admin/templates.t b/t/app/controller/admin/templates.t index 6944f4b04..39903deb1 100644 --- a/t/app/controller/admin/templates.t +++ b/t/app/controller/admin/templates.t @@ -45,8 +45,6 @@ my $report = FixMyStreet::App->model('DB::Problem')->find_or_create( } ); -$mech->log_in_ok( $superuser->email ); - my $report_id = $report->id; ok $report, "created test report - $report_id"; @@ -63,7 +61,13 @@ subtest "response templates can be added" => sub { }; $mech->submit_form_ok( { with_fields => $fields } ); - is $oxfordshire->response_templates->count, 1, "Response template was added"; + is $oxfordshire->response_templates->count, 1, "Response template was added"; +}; + +subtest 'check log of the above' => sub { + my $template_id = $oxfordshire->response_templates->first->id; + $mech->get_ok('/admin/users/' . $superuser->id . '/log'); + $mech->content_contains('Added template <a href="/admin/templates/' . $oxfordshire->id . '/' . $template_id . '">Report acknowledgement</a>'); }; subtest "but not another with the same title" => sub { @@ -217,7 +221,6 @@ subtest "auto-response templates that duplicate external_status_code can't be ad }); is $oxfordshire->response_templates->count, 1, "Initial response template was created"; - $mech->log_in_ok( $superuser->email ); $mech->get_ok( "/admin/templates/" . $oxfordshire->id . "/new" ); diff --git a/t/app/controller/admin/users.t b/t/app/controller/admin/users.t index 17256a214..95aac051d 100644 --- a/t/app/controller/admin/users.t +++ b/t/app/controller/admin/users.t @@ -3,6 +3,7 @@ use FixMyStreet::TestMech; my $mech = FixMyStreet::TestMech->new; my $user = $mech->create_user_ok('test@example.com', name => 'Test User'); +my $original_user_id = $user->id; # For log later my $user2 = $mech->create_user_ok('test2@example.com', name => 'Test User 2'); my $user3 = $mech->create_user_ok('test3@example.com', name => 'Test User 3'); @@ -649,4 +650,25 @@ subtest "View timeline" => sub { $mech->get_ok('/admin/timeline'); }; +subtest 'View user log' => sub { + my $p = FixMyStreet::DB->resultset('Problem')->search({ user_id => $user->id })->first; + $user->add_to_planned_reports($p); + + # User 1 created all the reports + my $id = $p->id; + $mech->get_ok('/admin/users?search=' . $user->email); + $mech->follow_link_ok({ text => 'Timeline', n => 2 }); + $mech->content_like(qr/Problem.*?>$id<\/a> created/); + $mech->content_like(qr/Problem.*?>$id<\/a> added to shortlist/); + + # User 3 edited user 2 above + $mech->get_ok('/admin/users/' . $user3->id . '/log'); + $mech->content_like(qr/Edited user.*?test2\@example/); + + # Superuser added a user, and merged one + $mech->get_ok('/admin/users/' . $superuser->id . '/log'); + $mech->content_like(qr/Added user.*?0156/); + $mech->content_like(qr/Merged user $original_user_id/); +}; + done_testing(); diff --git a/t/app/controller/moderate.t b/t/app/controller/moderate.t index e22d9edbc..fdbd0abb6 100644 --- a/t/app/controller/moderate.t +++ b/t/app/controller/moderate.t @@ -475,8 +475,6 @@ subtest 'updates' => sub { }}); $mech->content_lacks('update good good bad good'); }; - - $update->moderation_original_data->delete; }; my $update2 = create_update(); @@ -515,4 +513,14 @@ subtest 'And do it as a superuser' => sub { subtest 'Check moderation history in admin' => sub { $mech->get_ok('/admin/report_edit/' . $report->id); }; + +subtest 'Check moderation in user log' => sub { + $mech->get_ok('/admin/users/' . $user->id . '/log'); + my $report_id = $report->id; + $mech->content_like(qr/Moderated report.*?$report_id/); + my $update_id = $update->id; + $mech->content_like(qr/Moderated update.*?$update_id/); +}; + + done_testing(); diff --git a/templates/web/base/admin/bodies/open311-form-fields.html b/templates/web/base/admin/bodies/open311-form-fields.html index be2f13af0..dbb0f84e2 100644 --- a/templates/web/base/admin/bodies/open311-form-fields.html +++ b/templates/web/base/admin/bodies/open311-form-fields.html @@ -90,7 +90,7 @@ <label for"comment_user_id">[% loc('User ID to attribute fetched comments to') %]</label> <input type="text" class="form-control" name="comment_user_id" value="[% object.comment_user_id %]"> [% IF object.comment_user_id %] - <a href="[% c.uri_for_action('admin/users/edit', object.comment_user_id) %]">[% loc('edit user') %]</a> + <a href="[% c.uri_for_action('admin/users/edit', [ object.comment_user_id ]) %]">[% loc('edit user') %]</a> [% END %] </p> diff --git a/templates/web/base/admin/list_updates.html b/templates/web/base/admin/list_updates.html index b23cd7ca3..e8abdddad 100644 --- a/templates/web/base/admin/list_updates.html +++ b/templates/web/base/admin/list_updates.html @@ -41,7 +41,7 @@ </small></td> <td rowspan=2> [% IF c.user.has_permission_to('report_edit', update.problem.bodies_str_ids) %] - <a href="[% c.uri_for( 'update_edit', update.id ) %]">[% loc('Edit') %]</a> + <a href="[% c.uri_for_action( 'admin/update_edit', [ update.id ] ) %]">[% loc('Edit') %]</a> [% END %] </td> </tr> diff --git a/templates/web/base/admin/problem_row.html b/templates/web/base/admin/problem_row.html index 99142af4e..8d74fec13 100644 --- a/templates/web/base/admin/problem_row.html +++ b/templates/web/base/admin/problem_row.html @@ -23,7 +23,7 @@ [% PROCESS value_or_nbsp value=problem.category_display %] <br>[%- IF edit_body_contacts -%] [% FOR body IN problem.bodies.values %] - <a href="[% c.uri_for('body', body.id ) %]">[% PROCESS value_or_nbsp value=body.name %]</a> + <a href="[% c.uri_for_action('admin/bodies/edit', [ body.id ] ) %]">[% PROCESS value_or_nbsp value=body.name %]</a> [% END %] [%- ELSE -%] [%- PROCESS value_or_nbsp value=problem.bodies_str -%] @@ -40,7 +40,7 @@ </small></td> <td> [% IF c.user.has_permission_to('report_edit', problem.bodies_str_ids) %] - <a href="[% c.uri_for( 'report_edit', problem.id ) %]">[% loc('Edit') %]</a> + <a href="[% c.uri_for_action( '/admin/reports/edit', [ problem.id ] ) %]">[% loc('Edit') %]</a> [% END %] </td> </tr> diff --git a/templates/web/base/admin/report_edit.html b/templates/web/base/admin/reports/edit.html index da800ff71..b4af705a9 100644 --- a/templates/web/base/admin/report_edit.html +++ b/templates/web/base/admin/reports/edit.html @@ -16,7 +16,7 @@ <div id="side"> -<form method="post" action="[% c.uri_for( 'report_edit', problem.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="post" action="[% c.uri_for_action( '/admin/reports/edit', [ problem.id ] ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > <input type="hidden" name="submit" value="1" > @@ -57,7 +57,7 @@ class="admin-offsite-link">[% problem.latitude %], [% problem.longitude %]</a> [% IF problem.bodies_str %] [% FOREACH body IN problem.bodies.values %] [% SET body_printed = 1 %] - <a href="[% c.uri_for('body', body.id) %]">[% body.name | html %]</a> + <a href="[% c.uri_for_action('admin/bodies/edit', [ body.id ]) %]">[% body.name | html %]</a> [%- ',' IF NOT loop.last %] [% END %] [% IF NOT body_printed %] diff --git a/templates/web/base/admin/reports.html b/templates/web/base/admin/reports/index.html index 7d8fe9561..adbd50224 100644 --- a/templates/web/base/admin/reports.html +++ b/templates/web/base/admin/reports/index.html @@ -1,7 +1,7 @@ [% INCLUDE 'admin/header.html' title=loc('Search Reports') %] [% PROCESS 'admin/report_blocks.html' %] -<form method="get" action="[% c.uri_for('reports') %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="get" action="[% c.uri_for_action('/admin/reports/index') %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <p><label for="search">[% loc('Search:') %]</label> <input class="form-control" type="text" name="search" size="30" id="search" value="[% searched | html %]"> </form> diff --git a/templates/web/base/admin/template_edit.html b/templates/web/base/admin/templates/edit.html index 87218c7dd..7ce67f96f 100644 --- a/templates/web/base/admin/template_edit.html +++ b/templates/web/base/admin/templates/edit.html @@ -4,7 +4,7 @@ [% UNLESS rt.id %]<h3>[% loc('New template') %]</h3>[% END %] <form method="post" - action="[% c.uri_for('templates', body.id, rt.id || 'new' ) %]" + action="[% c.uri_for_action('/admin/templates/edit', body.id, rt.id || 'new' ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8" class="validate"> diff --git a/templates/web/base/admin/templates_index.html b/templates/web/base/admin/templates/index.html index 40e1be300..9322c6ef6 100644 --- a/templates/web/base/admin/templates_index.html +++ b/templates/web/base/admin/templates/index.html @@ -3,7 +3,7 @@ <ul> [% FOR body IN bodies %] <li> - <a href="[% c.uri_for('templates', body.id) %]">[% body.name %]</a> + <a href="[% c.uri_for_action('/admin/templates/view', body.id) %]">[% body.name %]</a> </li> [% END %] </ul> diff --git a/templates/web/base/admin/templates.html b/templates/web/base/admin/templates/view.html index 21e4eea25..6a1dd9614 100644 --- a/templates/web/base/admin/templates.html +++ b/templates/web/base/admin/templates/view.html @@ -28,12 +28,12 @@ [% IF t.external_status_code %][% t.external_status_code | html %] (external)[% END %] </td> <td> [% IF t.auto_response %]X[% END %] </td> - <td> <a href="[% c.uri_for('templates', body.id, t.id) %]" class="btn">[% loc('Edit') %]</a> </td> + <td> <a href="[% c.uri_for_action('/admin/templates/edit', body.id, t.id) %]" class="btn">[% loc('Edit') %]</a> </td> </tr> [% END %] </tbody> </table> -<a href="[% c.uri_for('templates', body.id, 'new') %]" class="btn">[% loc('New template') %]</a> +<a href="[% c.uri_for_action('/admin/templates/index', body.id, 'new') %]" class="btn">[% loc('New template') %]</a> [% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/admin/users/form.html b/templates/web/base/admin/users/form.html index f141dc02c..495da8648 100644 --- a/templates/web/base/admin/users/form.html +++ b/templates/web/base/admin/users/form.html @@ -1,4 +1,7 @@ -<form method="post" id="user_edit" action="[% c.uri_for_action( 'admin/users/edit', user.id || 'add' ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="post" id="user_edit" action="[% + SET action_end = user.id || 'add'; + c.uri_for_action( 'admin/users/edit', [ action_end ] ) + %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > <input type="hidden" name="submit" value="1" > diff --git a/templates/web/base/admin/users/import.html b/templates/web/base/admin/users/import.html index 6e11c74a9..38e4fe240 100644 --- a/templates/web/base/admin/users/import.html +++ b/templates/web/base/admin/users/import.html @@ -27,7 +27,7 @@ [% FOREACH user IN new_users %] <tr> <td> - <a href="[% c.uri_for_action( 'admin/users/edit', user.id ) %]"> + <a href="[% c.uri_for_action( 'admin/users/edit', [ user.id ] ) %]"> [% user.name %] </a> </td> @@ -50,7 +50,7 @@ [% FOREACH user IN existing_users %] <tr> <td> - <a href="[% c.uri_for_action( 'admin/users/edit', user.id ) %]"> + <a href="[% c.uri_for_action( 'admin/users/edit', [ user.id ] ) %]"> [% user.name %] </a> </td> diff --git a/templates/web/base/admin/users/index.html b/templates/web/base/admin/users/index.html index f48893cb0..e573c10fe 100644 --- a/templates/web/base/admin/users/index.html +++ b/templates/web/base/admin/users/index.html @@ -46,7 +46,7 @@ [% IF c.cobrand.moniker != 'zurich' %] <th>[% loc('Flagged') %]</th> [% END %] - <th>*</th> + <th colspan="2">*</th> </tr> [%- FOREACH user IN users %] <tr> @@ -57,14 +57,15 @@ [% PROCESS value_or_nbsp value=user.name %] [% IF user.from_body %]</label>[% END %] </td> - <td><a href="[% c.uri_for_action( 'admin/reports', search => user.email ) %]">[% PROCESS value_or_nbsp value=user.email %]</a></td> + <td><a href="[% c.uri_for_action( 'admin/reports/index', search => user.email ) %]">[% PROCESS value_or_nbsp value=user.email %]</a></td> <td>[% PROCESS value_or_nbsp value=user.from_body.name %] [% IF user.is_superuser %] * [% END %] </td> [% IF c.cobrand.moniker != 'zurich' %] <td>[% user.flagged == 2 ? loc('User in abuse table') : user.flagged ? loc('Yes') : ' ' %]</td> [% END %] - <td>[% IF user.id %]<a href="[% c.uri_for_action( 'admin/users/edit', user.id ) %]">[% loc('Edit') %]</a>[% END %]</td> + <td>[% IF user.id %]<a href="[% c.uri_for_action( 'admin/users/edit', [ user.id ] ) %]">[% loc('Edit') %]</a>[% END %]</td> + <td>[% IF user.id %]<a href="[% c.uri_for_action( 'admin/users/log', [ user.id ] ) %]">[% loc('Timeline') %]</a>[% END %]</td> </tr> [%- END -%] </table> diff --git a/templates/web/base/admin/users/log.html b/templates/web/base/admin/users/log.html new file mode 100644 index 000000000..a596d040c --- /dev/null +++ b/templates/web/base/admin/users/log.html @@ -0,0 +1,72 @@ +[% INCLUDE 'admin/header.html' title=loc('Timeline') _ ', ' _ user.name %] + +<style> +.timeline ul { + margin-bottom: 0; +} +.timeline dd { + margin-bottom: 0; +} +</style> + +[% +action_map = { + add = 'Added' + delete = 'Deleted' + edit = 'Edited' + merge = 'Merged' + moderation = 'Moderated' + resend = 'Resent' + category_change = 'Changed category of' + state_change = 'Changed state of' +} +%] + +[%- date = '' %] +[% FOREACH moment IN time.keys.sort.reverse %] + [%- curdate = time.$moment.0.date.strftime('%A, %e %B %Y') -%] + [%- IF date != curdate %] + [% '</dl>' IF date %] + <h2>[% curdate %]</h2> + + <dl class="timeline"> + [%- date = curdate -%] + [%- END -%] + <dt><b>[% time.$moment.0.date.hms %]</b></dt> + <dd><ul> + [% FOREACH item IN time.$moment %] + <li> + [%~ IF item.obj.problem_id %] + [%~ SET report_url = c.uri_for( '/report', item.obj.problem_id ) _ "#update_" _ item.obj.id %] + [%~ ELSE %] + [%~ SET report_url = c.uri_for('/report', item.obj.id) %] + [%~ END %] + [%~ SET report_link = "<a href='" _ report_url _ "'>" _ item.obj.id _ "</a>" %] + [%- SWITCH item.type -%] + [%~ CASE 'problem' %] + [%- tprintf(loc('Problem %s created'), report_link) %], ‘[% item.obj.title | html %]’ + [%~ CASE 'problemContributedBy' %] + [%- tprintf(loc('Problem %s created on behalf of %s'), report_link, item.obj.name) %], ‘[% item.obj.title | html %]’ + [%~ CASE 'update' %] + [% tprintf(loc("Update %s created for problem %d"), report_link, item.obj.problem_id) %] + [% item.obj.text | add_links | markup(item.obj.user) | html_para %] + [%~ CASE 'shortlistAdded' %] + [%- tprintf(loc('Problem %s added to shortlist'), report_link) %] + [%~ CASE 'shortlistRemoved' %] + [%- tprintf(loc('Problem %s removed from shortlist'), report_link) %] + [%~ CASE 'log' %] + [%~ SET object_summary = item.log.object_summary %] + [% IF object_summary %] + [%~ SET link = tprintf('<a href="%s">%s</a>', item.log.link, object_summary) %] + [%- tprintf('%s %s %s', action_map.${item.log.action}, item.log.actual_object_type, link) %] + [% ' – ' _ item.log.reason IF item.log.reason %] + [% ELSE %] + [%- tprintf('%s %s %s', action_map.${item.log.action}, item.log.actual_object_type, item.log.object_id) %] + [% END %] + [%- END %] + </li> + [%- END %] + </ul></dd> +[% END %] + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/report/inspect/information.html b/templates/web/base/report/inspect/information.html index cc8989522..b81b37543 100644 --- a/templates/web/base/report/inspect/information.html +++ b/templates/web/base/report/inspect/information.html @@ -7,7 +7,7 @@ <strong>[% loc('Report ID:') %]</strong> <span class="js-report-id">[% problem.id %]</span> [% IF c.user_exists AND c.cobrand.admin_allow_user(c.user) AND c.user.has_permission_to('report_edit', problem.bodies_str_ids) %] - (<a href="[% c.uri_for_action( "admin/report_edit", problem.id ) %]">[% loc('admin') %]</a>) + (<a href="[% c.uri_for_action( 'admin/reports/edit', [ problem.id ] ) %]">[% loc('admin') %]</a>) [% END %] </p> [% IF permissions.report_inspect AND problem.user.phone %] diff --git a/templates/web/zurich/admin/problem_row.html b/templates/web/zurich/admin/problem_row.html index 973d9f651..502a7bc39 100644 --- a/templates/web/zurich/admin/problem_row.html +++ b/templates/web/zurich/admin/problem_row.html @@ -46,7 +46,7 @@ </td> [% IF NOT no_edit %] - <td><a href="[% c.uri_for( 'report_edit', problem.id ) %]">[% loc('Edit') %]</a></td> + <td><a href="[% c.uri_for_action( 'admin/reports/edit', [ problem.id ] ) %]">[% loc('Edit') %]</a></td> [% END %] </tr> [%- END -%] diff --git a/templates/web/zurich/admin/report_edit-sdm.html b/templates/web/zurich/admin/report_edit-sdm.html index d8e6c2625..8d25ab40a 100644 --- a/templates/web/zurich/admin/report_edit-sdm.html +++ b/templates/web/zurich/admin/report_edit-sdm.html @@ -12,7 +12,7 @@ <div id="map_sidebar"> -<form method="post" action="[% c.uri_for( 'report_edit', problem.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="post" action="[% c.uri_for_action( 'admin/reports/edit', [ problem.id ] ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > <input type="hidden" name="submit" value="1" > diff --git a/templates/web/zurich/admin/report_edit.html b/templates/web/zurich/admin/reports/edit.html index 6f69161fe..4b0490b4a 100644 --- a/templates/web/zurich/admin/report_edit.html +++ b/templates/web/zurich/admin/reports/edit.html @@ -14,7 +14,7 @@ [% pstate = problem.get_extra_metadata('closure_status') || problem.state %] -<form id="report_edit" method="post" action="[% c.uri_for( 'report_edit', problem.id ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form id="report_edit" method="post" action="[% c.uri_for_action( 'admin/reports/edit', [ problem.id ] ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <input type="hidden" name="token" value="[% csrf_token %]" > <input type="hidden" name="submit" value="1" > diff --git a/templates/web/zurich/admin/reports.html b/templates/web/zurich/admin/reports/index.html index 7e425c8ec..481dfb49d 100644 --- a/templates/web/zurich/admin/reports.html +++ b/templates/web/zurich/admin/reports/index.html @@ -1,7 +1,7 @@ [% PROCESS 'admin/header.html' title=loc('Search Reports') %] [% PROCESS 'admin/report_blocks.html' %] -<form method="get" action="[% c.uri_for('reports') %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> +<form method="get" action="[% c.uri_for_action('admin/reports/index') %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8"> <p><label for="search">[% loc('Search:') %]</label> <input type="text" name="search" size="30" id="search" value="[% searched | html %]"> </form> diff --git a/templates/web/zurich/admin/template_edit.html b/templates/web/zurich/admin/templates/edit.html index b6f68106c..8a95f2a04 100644 --- a/templates/web/zurich/admin/template_edit.html +++ b/templates/web/zurich/admin/templates/edit.html @@ -11,7 +11,7 @@ </h3> <form method="post" - action="[% c.uri_for('templates', body.id, rt.id || 'new' ) %]" + action="[% c.uri_for_action('/admin/templates/edit', body.id, rt.id || 'new' ) %]" enctype="application/x-www-form-urlencoded" accept-charset="utf-8" class="validate"> diff --git a/templates/web/zurich/admin/templates.html b/templates/web/zurich/admin/templates/view.html index 2db9e2e34..7b95c8a99 100644 --- a/templates/web/zurich/admin/templates.html +++ b/templates/web/zurich/admin/templates/view.html @@ -17,12 +17,12 @@ <td> [% t.title %] </td> <td> [% t.text %] </td> <td> [% t.created %] </td> - <td> <a href="[% c.uri_for('templates', body.id, t.id) %]" class="btn">[% loc('Edit') %]</a> </td> + <td> <a href="[% c.uri_for_action('/admin/templates/edit', body.id, t.id) %]" class="btn">[% loc('Edit') %]</a> </td> </tr> [% END %] </tbody> </table> -<a href="[% c.uri_for('templates', body.id, 'new') %]" class="btn">[% loc('New template') %]</a> +<a href="[% c.uri_for_action('/admin/templates/edit', body.id, 'new') %]" class="btn">[% loc('New template') %]</a> [% INCLUDE 'admin/footer.html' %] |