aboutsummaryrefslogtreecommitdiffstats
path: root/perllib/FixMyStreet/App/Controller
diff options
context:
space:
mode:
Diffstat (limited to 'perllib/FixMyStreet/App/Controller')
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin.pm832
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Bodies.pm317
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm2
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm81
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm99
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Reports.pm523
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm140
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Roles.pm102
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Stats.pm46
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Templates.pm181
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Triage.pm163
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Users.pm313
-rw-r--r--perllib/FixMyStreet/App/Controller/Alert.pm45
-rw-r--r--perllib/FixMyStreet/App/Controller/Around.pm29
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm164
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Profile.pm38
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Social.pm230
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact.pm42
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm119
-rw-r--r--perllib/FixMyStreet/App/Controller/Dashboard.pm136
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Develop.pm31
-rw-r--r--perllib/FixMyStreet/App/Controller/Location.pm20
-rw-r--r--perllib/FixMyStreet/App/Controller/Moderate.pm10
-rw-r--r--perllib/FixMyStreet/App/Controller/My.pm31
-rw-r--r--perllib/FixMyStreet/App/Controller/Offline.pm109
-rw-r--r--perllib/FixMyStreet/App/Controller/Open311.pm26
-rw-r--r--perllib/FixMyStreet/App/Controller/Open311/Updates.pm88
-rw-r--r--perllib/FixMyStreet/App/Controller/Photo.pm37
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Questionnaire.pm6
-rw-r--r--perllib/FixMyStreet/App/Controller/Report.pm127
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/New.pm408
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/Update.pm73
-rw-r--r--perllib/FixMyStreet/App/Controller/Reports.pm158
-rw-r--r--perllib/FixMyStreet/App/Controller/Root.pm33
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Rss.pm26
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Status.pm2
-rw-r--r--perllib/FixMyStreet/App/Controller/Test.pm60
-rw-r--r--perllib/FixMyStreet/App/Controller/Tokens.pm4
38 files changed, 3312 insertions, 1539 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm
index 2f4669456..038cba9e5 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
@@ -72,57 +61,27 @@ Displays some summary information for the requests.
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
- if ($c->cobrand->moniker eq 'zurich' && $c->stash->{admin_type} ne 'super') {
- return $c->cobrand->admin();
+ if ($c->cobrand->moniker eq 'zurich') {
+ if ($c->stash->{admin_type} eq 'super') {
+ $c->forward('/admin/stats/gather');
+ return 1;
+ } else {
+ return $c->cobrand->admin();
+ }
}
- $c->forward('/admin/stats/state');
-
my @unsent = $c->cobrand->problems->search( {
state => [ FixMyStreet::DB::Result::Problem::open_states() ],
whensent => undef,
bodies_str => { '!=', undef },
+ # Ignore very recent ones that probably just haven't been sent yet
+ confirmed => { '<', \"current_timestamp - '5 minutes'::interval" },
+ },
+ {
+ order_by => 'confirmed',
} )->all;
$c->stash->{unsent_reports} = \@unsent;
- my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction );
-
- my %alert_counts =
- map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all;
-
- $alert_counts{0} ||= 0;
- $alert_counts{1} ||= 0;
-
- $c->stash->{alerts} = \%alert_counts;
-
- my $contacts = $c->model('DB::Contact')->summary_count();
-
- my %contact_counts =
- map { $_->state => $_->get_column('state_count') } $contacts->all;
-
- $contact_counts{confirmed} ||= 0;
- $contact_counts{unconfirmed} ||= 0;
- $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed};
-
- $c->stash->{contacts} = \%contact_counts;
-
- my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction );
-
- my %questionnaire_counts = map {
- $_->get_column('answered') => $_->get_column('questionnaire_count')
- } $questionnaires->all;
- $questionnaire_counts{1} ||= 0;
- $questionnaire_counts{0} ||= 0;
-
- $questionnaire_counts{total} =
- $questionnaire_counts{0} + $questionnaire_counts{1};
- $c->stash->{questionnaires_pc} =
- $questionnaire_counts{total}
- ? sprintf( '%.1f',
- $questionnaire_counts{1} / $questionnaire_counts{total} * 100 )
- : _('n/a');
- $c->stash->{questionnaires} = \%questionnaire_counts;
-
$c->forward('fetch_all_bodies');
return 1;
@@ -143,47 +102,38 @@ sub timeline : Path( 'timeline' ) : Args(0) {
my %time;
- try {
- $c->model('DB')->schema->storage->sql_maker->quote_char( '"' );
- $c->model('DB')->schema->storage->sql_maker->name_sep( '.' );
+ my $probs = $c->cobrand->problems->timeline;
- my $probs = $c->cobrand->problems->timeline;
-
- foreach ($probs->all) {
- push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ };
- push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed;
- push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent;
- }
+ foreach ($probs->all) {
+ push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ };
+ push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed;
+ push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent;
+ }
- my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction );
+ my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction );
- foreach ($questionnaires->all) {
- push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ };
- push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered;
- }
+ foreach ($questionnaires->all) {
+ push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ };
+ push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered;
+ }
- my $updates = $c->cobrand->updates->timeline;
+ my $updates = $c->cobrand->updates->timeline;
- foreach ($updates->all) {
- push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ;
- }
+ foreach ($updates->all) {
+ push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ;
+ }
- my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction );
+ my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction );
- foreach ($alerts->all) {
- push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ };
- }
+ foreach ($alerts->all) {
+ push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ };
+ }
- $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction );
+ $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction );
- foreach ($alerts->all) {
- push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ };
- }
- } catch {
- die $_;
- } finally {
- $c->model('DB')->schema->storage->sql_maker->quote_char( '' );
- };
+ foreach ($alerts->all) {
+ push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ };
+ }
$c->stash->{time} = \%time;
@@ -195,7 +145,7 @@ sub fetch_contacts : Private {
my $contacts = $c->stash->{body}->contacts->search(undef, { order_by => [ 'category' ] } );
$c->stash->{contacts} = $contacts;
- $c->stash->{live_contacts} = $contacts->not_deleted;
+ $c->stash->{live_contacts} = $contacts->not_deleted_admin;
$c->stash->{any_not_confirmed} = $contacts->search({ state => 'unconfirmed' })->count;
if ( $c->get_param('text') && $c->get_param('text') eq '1' ) {
@@ -221,167 +171,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') || 'created';
- my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1;
- $c->stash->{order} = $order;
- $c->stash->{dir} = $dir;
- $order .= ' desc' if $dir;
-
- 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};
-
- # when DBIC creates the join it does 'JOIN users user' in the
- # SQL which makes PostgreSQL unhappy as user is a reserved
- # word. So look up user ID for email separately.
- my @user_ids = $c->model('DB::User')->search({
- email => { ilike => $like_search },
- }, { columns => [ 'id' ] } )->all;
- @user_ids = map { $_->id } @user_ids;
-
- my @user_ids_phone = $c->model('DB::User')->search({
- phone => { ilike => $like_search },
- }, { columns => [ 'id' ] } )->all;
- @user_ids_phone = map { $_->id } @user_ids_phone;
-
- if ($valid_email) {
- $query->{'-or'} = [
- 'me.user_id' => { -in => \@user_ids },
- ];
- } elsif ($valid_phone) {
- $query->{'-or'} = [
- 'me.user_id' => { -in => \@user_ids_phone },
- ];
- } elsif ($search =~ /^id:(\d+)$/) {
- $query->{'-or'} = [
- 'me.id' => int($1),
- ];
- } 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,
- 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] },
- 'me.external_id' => { ilike => $like_search },
- 'me.name' => { ilike => $like_search },
- 'me.title' => { ilike => $like_search },
- detail => { ilike => $like_search },
- bodies_str => { like => $like_search },
- cobrand_data => { like => $like_search },
- ];
- }
-
- my $problems = $c->cobrand->problems->search(
- $query,
- {
- rows => 50,
- order_by => [ \"(state='hidden')", \$order ]
- }
- )->page( $p_page );
-
- $c->stash->{problems} = [ $problems->all ];
- $c->stash->{problems_pager} = $problems->pager;
-
- if ($valid_email) {
- $query = [
- 'me.user_id' => { -in => \@user_ids },
- ];
- } elsif ($valid_phone) {
- $query = [
- 'me.user_id' => { -in => \@user_ids_phone },
- ];
- } elsif ($search =~ /^id:(\d+)$/) {
- $query = [
- 'me.id' => int($1),
- 'me.problem_id' => int($1),
- ];
- } elsif ($search =~ /^area:(\d+)$/) {
- $query = [];
- } else {
- $query = [
- 'me.id' => $search_n,
- 'problem.id' => $search_n,
- 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] },
- '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/problem/],
- rows => 50,
- order_by => [ \"(me.state='hidden')", \"(problem.state='hidden')", { -desc => 'me.created' } ]
- }
- )->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'));
@@ -395,471 +184,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->forward('/auth/check_csrf_token');
-
- $problem->resend;
- $problem->update();
- $c->stash->{status_message} =
- '<p><em>' . _('That problem will now be resent.') . '</em></p>';
-
- $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} = '<p><em>' . _('That problem has been marked as sent.') . '</em></p>';
- $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->get_photoset->delete_cached;
- }
-
- 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} =
- '<p><em>' . _('Updated!') . '</em></p>';
-
- # 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 $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 (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 (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}} };
-}
-
-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') );
- $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 => { '!=' => \@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 ) = @_;
@@ -872,8 +196,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;
}
@@ -907,18 +231,17 @@ 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} = '<p><em>' . _('Updated!') . '</em></p>';
+ $c->stash->{status_message} = _('Updated!');
# Must call update->hide while it's not hidden (so is_latest works)
if ($new_state eq 'hidden') {
my $outcome = $update->hide;
- $c->stash->{status_message} .=
- '<p><em>' . _('Problem marked as open.') . '</em></p>'
+ $c->stash->{status_message} .= _('Problem marked as open.')
if $outcome->{reopened};
}
@@ -1014,8 +337,8 @@ sub set_allowed_pages : Private {
sub get_user : Private {
my ( $self, $c ) = @_;
- my $user = $c->req->remote_user();
- $user ||= ($c->user && $c->user->name);
+ my $user = ($c->user && $c->user->name);
+ $user ||= $c->req->remote_user();
$user ||= '';
return $user;
@@ -1075,7 +398,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+)$/;
@@ -1105,7 +428,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');
@@ -1117,8 +440,8 @@ sub _get_remove_photo_param {
sub remove_photo : Private {
my ($self, $c, $object, $keys) = @_;
if ($keys eq 'ALL') {
- $object->photo(undef);
$object->get_photoset->delete_cached;
+ $object->photo(undef);
} else {
my $fileids = $object->get_photoset->remove_images($keys);
$object->photo($fileids);
@@ -1194,31 +517,46 @@ sub update_extra_fields : Private {
my $meta = {};
$meta->{code} = $c->get_param("metadata[$i].code");
next unless $meta->{code};
+
$meta->{order} = int $c->get_param("metadata[$i].order");
- $meta->{datatype} = $c->get_param("metadata[$i].datatype");
- my $required = $c->get_param("metadata[$i].required") && $c->get_param("metadata[$i].required") eq 'on';
- $meta->{required} = $required ? 'true' : 'false';
- my $notice = $c->get_param("metadata[$i].notice") && $c->get_param("metadata[$i].notice") eq 'on';
- $meta->{variable} = $notice ? 'false' : 'true';
- $meta->{description} = $c->get_param("metadata[$i].description");
- $meta->{datatype_description} = $c->get_param("metadata[$i].datatype_description");
- $meta->{automated} = $c->get_param("metadata[$i].automated")
- if $c->get_param("metadata[$i].automated");
-
- if ( $meta->{datatype} eq "singlevaluelist" ) {
- $meta->{values} = [];
- my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key};
- my @vindices = grep { /$re/ } keys %{ $c->req->params };
- @vindices = sort map { /values\[(\d+)\]/ } @vindices;
- foreach my $j (@vindices) {
- my $name = $c->get_param("metadata[$i].values[$j].name");
- my $key = $c->get_param("metadata[$i].values[$j].key");
- push(@{$meta->{values}}, {
- name => $name,
- key => $key,
- }) if $name;
+ $meta->{protected} = $c->get_param("metadata[$i].protected") ? 'true' : 'false';
+
+ my $behaviour = $c->get_param("metadata[$i].behaviour") || 'question';
+ if ($behaviour eq 'question') {
+ $meta->{required} = $c->get_param("metadata[$i].required") ? 'true' : 'false';
+ $meta->{variable} = 'true';
+ my $desc = $c->get_param("metadata[$i].description");
+ $meta->{description} = FixMyStreet::Template::sanitize($desc);
+ $meta->{datatype} = $c->get_param("metadata[$i].datatype");
+
+ if ( $meta->{datatype} eq "singlevaluelist" ) {
+ $meta->{values} = [];
+ my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key};
+ my @vindices = grep { /$re/ } keys %{ $c->req->params };
+ @vindices = sort map { /values\[(\d+)\]/ } @vindices;
+ foreach my $j (@vindices) {
+ my $name = $c->get_param("metadata[$i].values[$j].name");
+ my $key = $c->get_param("metadata[$i].values[$j].key");
+ my $disable = $c->get_param("metadata[$i].values[$j].disable");
+ my $disable_message = $c->get_param("metadata[$i].values[$j].disable_message");
+ push(@{$meta->{values}}, {
+ name => $name,
+ key => $key,
+ $disable ? (disable => 1, disable_message => $disable_message) : (),
+ }) if $name;
+ }
}
+ } elsif ($behaviour eq 'notice') {
+ $meta->{variable} = 'false';
+ my $desc = $c->get_param("metadata[$i].description");
+ $meta->{description} = FixMyStreet::Template::sanitize($desc);
+ $meta->{disable_form} = $c->get_param("metadata[$i].disable_form") ? 'true' : 'false';
+ } elsif ($behaviour eq 'hidden') {
+ $meta->{automated} = 'hidden_field';
+ } elsif ($behaviour eq 'server') {
+ $meta->{automated} = 'server_set';
}
+
push @extra_fields, $meta;
}
@extra_fields = sort { $a->{order} <=> $b->{order} } @extra_fields;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
index 0e47d2238..6ae068cd9 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
@@ -51,27 +51,7 @@ sub index : Path : Args(0) {
my $posted = $c->get_param('posted') || '';
if ( $posted eq 'body' ) {
- $c->forward('check_for_super_user');
- $c->forward('/auth/check_csrf_token');
-
- my $values = $c->forward('body_params');
- unless ( keys %{$c->stash->{body_errors}} ) {
- my $body = $c->model('DB::Body')->create( $values->{params} );
- if ($values->{extras}) {
- $body->set_extra_metadata( $_ => $values->{extras}->{$_} )
- for keys %{$values->{extras}};
- $body->update;
- }
- my @area_ids = $c->get_param_list('area_ids');
- foreach (@area_ids) {
- $c->model('DB::BodyArea')->create( { body => $body, area_id => $_ } );
- }
-
- $c->stash->{object} = $body;
- $c->stash->{translation_col} = 'name';
- $c->forward('update_translations');
- $c->stash->{updated} = _('New body added');
- }
+ $c->forward('update_body', [ undef, _('New body added') ]);
}
$c->forward( '/admin/fetch_all_bodies' );
@@ -147,6 +127,8 @@ sub edit : Chained('body') : PathPart('') : Args(0) {
# to display email addresses as text
$c->stash->{template} = 'admin/bodies/body.html';
$c->forward('/admin/fetch_contacts');
+ $c->stash->{contacts} = [ $c->stash->{contacts}->all ];
+ $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]);
return 1;
}
@@ -158,7 +140,8 @@ sub category : Chained('body') : PathPart('') {
$c->forward( '/auth/get_csrf_token' );
my $contact = $c->stash->{body}->contacts->search( { category => $category } )->first;
- $c->stash->{contact} = $contact;
+ $c->detach( '/page_error_404_not_found', [] ) unless $contact;
+ $c->stash->{contact} = $c->stash->{current_contact} = $contact;
$c->stash->{translation_col} = 'category';
$c->stash->{object} = $c->stash->{contact};
@@ -220,140 +203,199 @@ sub check_for_super_user : Private {
sub update_contacts : Private {
my ( $self, $c ) = @_;
- my $posted = $c->get_param('posted');
- my $editor = $c->forward('/admin/get_user');
-
+ my $posted = $c->get_param('posted') || '';
if ( $posted eq 'new' ) {
- $c->forward('/auth/check_csrf_token');
+ $c->forward('update_contact');
+ } elsif ( $posted eq 'update' ) {
+ $c->forward('confirm_contacts');
+ } elsif ( $posted eq 'body' ) {
+ $c->forward('update_body', [ $c->stash->{body}, _('Values updated') ]);
+ }
+}
- my %errors;
+sub update_contact : Private {
+ my ( $self, $c ) = @_;
- my $category = $self->trim( $c->get_param('category') );
- $errors{category} = _("Please choose a category") unless $category;
- $errors{note} = _('Please enter a message') unless $c->get_param('note');
+ my $editor = $c->forward('/admin/get_user');
+ $c->forward('/auth/check_csrf_token');
- my $contact = $c->model('DB::Contact')->find_or_new(
- {
- body_id => $c->stash->{body_id},
- category => $category,
- }
- );
+ my %errors;
- 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_list($email) || $email eq 'REFUSED';
- }
+ my $current_category = $c->get_param('current_category') || '';
+ my $current_contact = $c->model('DB::Contact')->find({
+ body_id => $c->stash->{body_id},
+ category => $current_category,
+ });
+ $c->stash->{current_contact} = $current_contact;
- $contact->email( $email );
- $contact->state( $c->get_param('state') );
- $contact->non_public( $c->get_param('non_public') ? 1 : 0 );
- $contact->note( $c->get_param('note') );
- $contact->whenedited( \'current_timestamp' );
- $contact->editor( $editor );
- $contact->endpoint( $c->get_param('endpoint') );
- $contact->jurisdiction( $c->get_param('jurisdiction') );
- $contact->api_key( $c->get_param('api_key') );
- $contact->send_method( $c->get_param('send_method') );
-
- # Set flags in extra to the appropriate values
- if ( $c->get_param('photo_required') ) {
- $contact->set_extra_metadata_if_undefined( photo_required => 1 );
- }
- else {
- $contact->unset_extra_metadata( 'photo_required' );
- }
- if ( $c->get_param('inspection_required') ) {
- $contact->set_extra_metadata( inspection_required => 1 );
- }
- else {
- $contact->unset_extra_metadata( 'inspection_required' );
- }
- if ( $c->get_param('reputation_threshold') ) {
- $contact->set_extra_metadata( reputation_threshold => int($c->get_param('reputation_threshold')) );
+ my $category = $self->trim( $c->get_param('category') );
+ $errors{category} = _("Please choose a category") unless $category;
+ $errors{note} = _('Please enter a message') unless $c->get_param('note') || FixMyStreet->config('STAGING_SITE');
+
+ my $contact = $c->model('DB::Contact')->find_or_new(
+ {
+ body_id => $c->stash->{body_id},
+ category => $category,
}
- if ( my $group = $c->get_param('group') ) {
- $contact->set_extra_metadata( group => $group );
- } else {
+ );
+ if ($current_contact && $contact->id && $contact->id != $current_contact->id) {
+ $errors{category} = _('You cannot rename a category to an existing category');
+ } elsif ($current_contact && !$contact->id) {
+ # Changed name
+ $contact = $current_contact;
+ $c->model('DB::Problem')->to_body($c->stash->{body_id})->search({ category => $current_category })->update({ category => $category });
+ $contact->category($category);
+ }
+
+ my $email = $c->get_param('email');
+ $email =~ s/\s+//g;
+ my $send_method = $c->get_param('send_method') || $contact->body->send_method || "";
+ my $email_unchanged = $contact->email && $email && $contact->email eq $email;
+ unless ( $send_method eq 'Open311' || $email_unchanged ) {
+ $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED';
+ }
+
+ $contact->email( $email );
+ $contact->state( $c->get_param('state') );
+ $contact->non_public( $c->get_param('non_public') ? 1 : 0 );
+ $contact->note( $c->get_param('note') );
+ $contact->whenedited( \'current_timestamp' );
+ $contact->editor( $editor );
+ $contact->endpoint( $c->get_param('endpoint') );
+ $contact->jurisdiction( $c->get_param('jurisdiction') );
+ $contact->api_key( $c->get_param('api_key') );
+ $contact->send_method( $c->get_param('send_method') );
+
+ # Set flags in extra to the appropriate values
+ if ( $c->get_param('photo_required') ) {
+ $contact->set_extra_metadata_if_undefined( photo_required => 1 );
+ } else {
+ $contact->unset_extra_metadata( 'photo_required' );
+ }
+ if ( $c->get_param('open311_protect') ) {
+ $contact->set_extra_metadata( open311_protect => 1 );
+ } else {
+ $contact->unset_extra_metadata( 'open311_protect' );
+ }
+ if ( my @group = $c->get_param_list('group') ) {
+ @group = grep { $_ } @group;
+ if (scalar @group == 0) {
$contact->unset_extra_metadata( 'group' );
+ } else {
+ $contact->set_extra_metadata( group => \@group );
}
+ } else {
+ $contact->unset_extra_metadata( 'group' );
+ }
+
+ $c->forward('/admin/update_extra_fields', [ $contact ]);
+ $c->forward('contact_cobrand_extra_fields', [ $contact, \%errors ]);
+
+ # Special form disabling form
+ if ($c->get_param('disable')) {
+ my $msg = $c->get_param('disable_message');
+ $msg = FixMyStreet::Template::sanitize($msg);
+ $errors{category} = _('Please enter a message') unless $msg;
+ my $meta = {
+ code => '_fms_disable_',
+ variable => 'false',
+ protected => 'true',
+ disable_form => 'true',
+ description => $msg,
+ };
+ $contact->update_extra_field($meta);
+ } else {
+ $contact->remove_extra_field('_fms_disable_');
+ }
+
+ if ( %errors ) {
+ $c->stash->{updated} = _('Please correct the errors below');
+ $c->stash->{contact} = $contact;
+ $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 ) {
+ $c->stash->{translation_col} = 'category';
+ $c->stash->{object} = $contact;
+ $c->forward('update_translations');
+ }
- $c->forward('/admin/update_extra_fields', [ $contact ]);
- $c->forward('contact_cobrand_extra_fields', [ $contact ]);
+}
- if ( %errors ) {
- $c->stash->{updated} = _('Please correct the errors below');
- $c->stash->{contact} = $contact;
- $c->stash->{errors} = \%errors;
- } elsif ( $contact->in_storage ) {
- $c->stash->{updated} = _('Values updated');
+sub confirm_contacts : Private {
+ my ( $self, $c ) = @_;
- # 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('/auth/check_csrf_token');
+
+ my @categories = $c->get_param_list('confirmed');
+
+ my $contacts = $c->model('DB::Contact')->search(
+ {
+ body_id => $c->stash->{body_id},
+ category => { -in => \@categories },
}
+ );
- unless ( %errors ) {
- $c->stash->{translation_col} = 'category';
- $c->stash->{object} = $contact;
- $c->forward('update_translations');
+ my $editor = $c->forward('/admin/get_user');
+ $contacts->update(
+ {
+ state => 'confirmed',
+ whenedited => \'current_timestamp',
+ note => 'Confirmed',
+ editor => $editor,
}
+ );
- } elsif ( $posted eq 'update' ) {
- $c->forward('/auth/check_csrf_token');
+ $c->forward('/admin/log_edit', [ $c->stash->{body_id}, 'body', 'edit' ]);
+ $c->stash->{updated} = _('Values updated');
+}
- my @categories = $c->get_param_list('confirmed');
+sub update_body : Private {
+ my ($self, $c, $body, $msg) = @_;
- my $contacts = $c->model('DB::Contact')->search(
- {
- body_id => $c->stash->{body_id},
- category => { -in => \@categories },
- }
- );
-
- $contacts->update(
- {
- state => 'confirmed',
- whenedited => \'current_timestamp',
- note => 'Confirmed',
- editor => $editor,
- }
- );
+ $c->forward('check_for_super_user');
+ $c->forward('/auth/check_csrf_token');
- $c->stash->{updated} = _('Values updated');
- } elsif ( $posted eq 'body' ) {
- $c->forward('check_for_super_user');
- $c->forward('/auth/check_csrf_token');
-
- my $values = $c->forward( 'body_params' );
- unless ( keys %{$c->stash->{body_errors}} ) {
- $c->stash->{body}->update( $values->{params} );
- if ($values->{extras}) {
- $c->stash->{body}->set_extra_metadata( $_ => $values->{extras}->{$_} )
- for keys %{$values->{extras}};
- $c->stash->{body}->update;
- }
- my @current = $c->stash->{body}->body_areas->all;
- my %current = map { $_->area_id => 1 } @current;
- my @area_ids = $c->get_param_list('area_ids');
- foreach (@area_ids) {
- $c->model('DB::BodyArea')->find_or_create( { body => $c->stash->{body}, area_id => $_ } );
- delete $current{$_};
- }
- # Remove any others
- $c->stash->{body}->body_areas->search( { area_id => [ keys %current ] } )->delete;
+ my $values = $c->forward('body_params');
+ return if %{$c->stash->{body_errors}};
- $c->stash->{translation_col} = 'name';
- $c->stash->{object} = $c->stash->{body};
- $c->forward('update_translations');
+ 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' ]);
+ }
- $c->stash->{updated} = _('Values updated');
- }
+ if ($values->{extras}) {
+ $body->set_extra_metadata( $_ => $values->{extras}->{$_} )
+ for keys %{$values->{extras}};
+ $body->update;
}
+ my @current = $body->body_areas->all;
+ my %current = map { $_->area_id => 1 } @current;
+ my @area_ids = $c->get_param_list('area_ids');
+ foreach (@area_ids) {
+ $c->model('DB::BodyArea')->find_or_create( { body => $body, area_id => $_ } );
+ delete $current{$_};
+ }
+ # Remove any others
+ $body->body_areas->search( { area_id => [ keys %current ] } )->delete;
+
+ $c->stash->{translation_col} = 'name';
+ $c->stash->{object} = $body;
+ $c->forward('update_translations');
+
+ $c->stash->{updated} = $msg;
}
sub body_params : Private {
@@ -375,9 +417,13 @@ sub body_params : Private {
);
my %params = map { $_ => $c->get_param($_) || $defaults{$_} } keys %defaults;
$c->forward('check_body_params', [ \%params ]);
+
my @extras = qw/fetch_all_problems/;
+ my $cobrand_extras = $c->cobrand->call_hook('body_extra_fields');
+ push @extras, @$cobrand_extras if $cobrand_extras;
+
%defaults = map { $_ => '' } @extras;
- my %extras = map { $_ => $c->get_param($_) || $defaults{$_} } @extras;
+ my %extras = map { $_ => $c->get_param("extra[$_]") || $defaults{$_} } @extras;
return { params => \%params, extras => \%extras };
}
@@ -392,12 +438,13 @@ sub check_body_params : Private {
}
sub contact_cobrand_extra_fields : Private {
- my ( $self, $c, $contact ) = @_;
+ my ( $self, $c, $contact, $errors ) = @_;
my $extra_fields = $c->cobrand->call_hook('contact_extra_fields');
foreach ( @$extra_fields ) {
$contact->set_extra_metadata( $_ => $c->get_param("extra[$_]") );
}
+ $c->cobrand->call_hook(contact_extra_fields_validation => $contact, $errors);
}
sub fetch_translations : Private {
diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
index ed9b40fd0..6c1a25e5a 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
@@ -76,7 +76,7 @@ sub edit : Path : Args(2) {
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 },
+ contact_id => { -not_in => \@new_contact_ids },
})->delete;
foreach my $contact_id (@new_contact_ids) {
$defect_type->contact_defect_types->find_or_create({
diff --git a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm
deleted file mode 100644
index 0026acb9c..000000000
--- a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm
+++ /dev/null
@@ -1,81 +0,0 @@
-package FixMyStreet::App::Controller::Admin::ExorDefects;
-use Moose;
-use namespace::autoclean;
-
-use DateTime;
-use Try::Tiny;
-use FixMyStreet::Integrations::ExorRDI;
-use FixMyStreet::DateRange;
-
-BEGIN { extends 'Catalyst::Controller'; }
-
-
-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 $range = FixMyStreet::DateRange->new(
- start_date => $c->get_param('start_date'),
- end_date => $c->get_param('end_date'),
- parser => DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' ),
- );
-
- my $params = {
- start_date => $range->start,
- inspection_date => $range->start,
- end_date => $range->end,
- user => $c->get_param('user_id'),
- mark_as_processed => 0,
- };
- my $rdi = FixMyStreet::Integrations::ExorRDI->new($params);
-
- try {
- my $out = $rdi->construct;
- $c->res->content_type('text/csv; charset=utf-8');
- $c->res->header('content-disposition' => "attachment; filename=" . $rdi->filename);
- $c->res->body( $out );
- } catch {
- die $_ unless $_ =~ /FixMyStreet::Integrations::ExorRDI::Error/;
- if ($params->{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} = $params->{start_date};
- $c->flash->{end_date} = $params->{end_date};
- $c->flash->{user_id} = $params->{user};
- $c->res->redirect( $c->uri_for( '' ) );
- };
-}
-
-1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm
new file mode 100644
index 000000000..9e3bdc33e
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm
@@ -0,0 +1,99 @@
+package FixMyStreet::App::Controller::Admin::ManifestTheme;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::App::Form::ManifestTheme;
+
+sub auto :Private {
+ my ($self, $c) = @_;
+
+ if ( $c->cobrand->moniker eq 'fixmystreet' ) {
+ $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({}), show_all => 1);
+ } else {
+ $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({ cobrand => $c->cobrand->moniker }));
+ }
+}
+
+sub index :Path :Args(0) {
+ my ( $self, $c ) = @_;
+
+ unless ( $c->stash->{show_all} ) {
+ if ( $c->stash->{rs}->count ) {
+ $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ]));
+ } else {
+ $c->res->redirect($c->uri_for($self->action_for('create')));
+ }
+ $c->detach;
+ }
+}
+
+sub item :PathPart('admin/manifesttheme') :Chained :CaptureArgs(1) {
+ my ($self, $c, $cobrand) = @_;
+
+ my $obj = $c->stash->{rs}->find({ cobrand => $cobrand })
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
+
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+
+ my $form = $self->form($c, $c->stash->{obj});
+
+ # We need to do this after form processing, in case a form POST has deleted
+ # an icon.
+ $c->stash->{editing_manifest_theme} = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]);
+
+ return $form;
+}
+
+
+sub create :Local :Args(0) {
+ my ($self, $c) = @_;
+
+ unless ( $c->stash->{show_all} || $c->stash->{rs}->count == 0) {
+ $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ]));
+ $c->detach;
+ }
+
+ my $theme = $c->stash->{rs}->new_result({});
+ return $self->form($c, $theme);
+}
+
+sub form {
+ my ($self, $c, $theme) = @_;
+
+ if ($c->get_param('delete_theme')) {
+ $c->forward('_delete_all_manifest_icons');
+ $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]);
+ $theme->delete;
+ $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', 'delete' ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+ $c->detach;
+ }
+
+ my $action = $theme->in_storage ? 'edit' : 'add';
+ my $form = FixMyStreet::App::Form::ManifestTheme->new( cobrand => $c->cobrand->moniker );
+ $c->stash(template => 'admin/manifesttheme/form.html', form => $form);
+ my $params = $c->req->params;
+ $params->{icon} = $c->req->upload('icon') if $params->{icon};
+ $form->process(item => $theme, params => $params);
+ return unless $form->validated;
+
+ $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', $action ]);
+ $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+}
+
+sub _delete_all_manifest_icons :Private {
+ my ($self, $c) = @_;
+
+ my $theme = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]);
+ foreach my $icon ( @{ $theme->{icons} } ) {
+ unlink FixMyStreet->path_to('web', $icon->{src});
+ }
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Reports.pm b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm
new file mode 100644
index 000000000..7300fe676
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm
@@ -0,0 +1,523 @@
+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,
+ {
+ join => 'user',
+ '+columns' => 'user.email',
+ 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,
+ },
+ {
+ '+columns' => ['user.email'],
+ join => 'user',
+ prefetch => [qw/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_button') ) {
+ $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 $force_resend = $c->cobrand->call_hook('category_change_force_resend', $problem->category, $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;
+ }
+ if ($force_resend) {
+ $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->stash->{categories_for_point} = 1;
+ $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/ResponsePriorities.pm b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
index 2613f6ae0..5e2908290 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
@@ -4,98 +4,94 @@ use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use FixMyStreet::App::Form::ResponsePriority;
-sub index : Path : Args(0) {
- my ( $self, $c ) = @_;
+sub auto :Private {
+ 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' );
+ $c->stash(rs => $c->model('DB::ResponsePriority')->search_rs(undef, {
+ prefetch => 'body',
+ order_by => ['body.name', 'me.name']
+ }));
+ } elsif ($user->from_body) {
+ $c->stash(rs => $user->from_body->response_priorities->search_rs(undef, {
+ order_by => 'name'
+ }));
}
}
-sub list : Path : Args(1) {
- my ($self, $c, $body_id) = @_;
-
- $c->forward('load_user_body', [ $body_id ]);
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
- my @priorities = $c->stash->{body}->response_priorities->search(
- undef,
- {
- order_by => 'name'
- }
+ if (my $body_id = $c->get_param('body_id')) {
+ $c->res->redirect($c->uri_for($self->action_for('create'), [ $body_id ]));
+ $c->detach;
+ }
+ if ($c->user->is_superuser) {
+ $c->forward('/admin/fetch_all_bodies');
+ }
+ $c->stash(
+ response_priorities => [ $c->stash->{rs}->all ],
);
-
- $c->stash->{response_priorities} = \@priorities;
}
-sub edit : Path : Args(2) {
- my ( $self, $c, $body_id, $priority_id ) = @_;
-
- $c->forward('load_user_body', [ $body_id ]);
+sub body :PathPart('admin/responsepriorities') :Chained :CaptureArgs(1) {
+ my ($self, $c, $body_id) = @_;
- my $priority;
- if ($priority_id eq 'new') {
- $priority = $c->stash->{body}->response_priorities->new({});
- }
- else {
- $priority = $c->stash->{body}->response_priorities->find( $priority_id )
- or $c->detach( '/page_error_404_not_found' );
+ my $user = $c->user;
+ if ($user->is_superuser) {
+ $c->stash->{body} = $c->model('DB::Body')->find($body_id);
+ } elsif ($user->from_body && $user->from_body->id == $body_id) {
+ $c->stash->{body} = $user->from_body;
}
- $c->forward('/admin/fetch_contacts');
- my @contacts = $priority->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') {
- $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->is_default( $c->get_param('is_default') ? 1 : 0 );
- $priority->update_or_insert;
-
- my @live_contact_ids = map { $_->id } @live_contacts;
- my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
- $priority->contact_response_priorities->search({
- contact_id => { '!=' => \@new_contact_ids },
- })->delete;
- foreach my $contact_id (@new_contact_ids) {
- $priority->contact_response_priorities->find_or_create({
- contact_id => $contact_id,
- });
- }
-
- $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) );
- }
+ $c->detach( '/page_error_404_not_found' ) unless $c->stash->{body};
+}
- $c->stash->{response_priority} = $priority;
+sub create :Chained('body') :Args(0) {
+ my ($self, $c) = @_;
+
+ my $priority = $c->stash->{rs}->new_result({ body => $c->stash->{body} });
+ return $self->form($c, $priority);
}
-sub load_user_body : Private {
- my ($self, $c, $body_id) = @_;
+sub item :PathPart('') :Chained('body') :CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
- my $has_permission = $c->user->has_body_permission_to('responsepriority_edit', $body_id);
+ my $obj = $c->stash->{rs}->find($id)
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
- unless ( $has_permission ) {
- $c->detach( '/page_error_404_not_found' );
- }
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+ return $self->form($c, $c->stash->{obj});
+}
+
+sub form {
+ my ($self, $c, $priority) = @_;
- $c->stash->{body} = $c->model('DB::Body')->find($body_id)
- or $c->detach( '/page_error_404_not_found' );
+ # Otherwise, the form includes contacts for *all* bodies
+ $c->forward('/admin/fetch_contacts');
+ my @all_contacts = map {
+ { value => $_->id, label => $_->category }
+ } $c->stash->{live_contacts}->all;
+
+ my $opts = {
+ field_list => [
+ '+contacts' => { options => \@all_contacts },
+ ],
+ body_id => $c->stash->{body}->id,
+ };
+
+ my $form = FixMyStreet::App::Form::ResponsePriority->new(%$opts);
+ $c->stash(template => 'admin/responsepriorities/edit.html', form => $form);
+ $form->process(item => $priority, params => $c->req->params);
+ return unless $form->validated;
+
+ $c->response->redirect($c->uri_for($self->action_for('index')));
}
__PACKAGE__->meta->make_immutable;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm
new file mode 100644
index 000000000..279ee695c
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm
@@ -0,0 +1,102 @@
+package FixMyStreet::App::Controller::Admin::Roles;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::App::Form::Role;
+
+sub auto :Private {
+ my ($self, $c) = @_;
+
+ my $user = $c->user;
+ if ($user->is_superuser) {
+ $c->stash(rs => $c->model('DB::Role')->search_rs({}, {
+ prefetch => 'body',
+ order_by => ['body.name', 'me.name']
+ }));
+ } elsif ($user->from_body) {
+ $c->stash(rs => $user->from_body->roles->search_rs({}, { order_by => 'name' }));
+ }
+}
+
+sub index :Path :Args(0) {
+ my ($self, $c) = @_;
+
+ my $p = $c->cobrand->available_permissions;
+ my %labels;
+ foreach my $group (sort keys %$p) {
+ my $group_vals = $p->{$group};
+ foreach (sort keys %$group_vals) {
+ $labels{$_} = $group_vals->{$_};
+ }
+ }
+
+ $c->stash(
+ roles => [ $c->stash->{rs}->all ],
+ labels => \%labels,
+ );
+}
+
+sub create :Local :Args(0) {
+ my ($self, $c) = @_;
+
+ my $role = $c->stash->{rs}->new_result({});
+ return $self->form($c, $role);
+}
+
+sub item :PathPart('admin/roles') :Chained :CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
+
+ my $obj = $c->stash->{rs}->find($id)
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
+
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+ return $self->form($c, $c->stash->{obj});
+}
+
+sub form {
+ my ($self, $c, $role) = @_;
+
+ 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;
+ }
+
+ my $perms = [];
+ my $p = $c->cobrand->available_permissions;
+ foreach my $group (sort keys %$p) {
+ my $group_vals = $p->{$group};
+ my @foo;
+ foreach (sort keys %$group_vals) {
+ push @foo, { value => $_, label => $group_vals->{$_} };
+ }
+ push @$perms, { group => $group, options => \@foo };
+ }
+ my $opts = {
+ field_list => [
+ '+permissions' => { options => $perms },
+ ],
+ };
+
+ if (!$c->user->is_superuser && $c->user->from_body) {
+ push @{$opts->{field_list}}, '+body', { inactive => 1 };
+ $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')));
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
index 5f82094d6..03b529a55 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
@@ -7,6 +7,52 @@ BEGIN { extends 'Catalyst::Controller'; }
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
return $c->cobrand->admin_stats() if $c->cobrand->moniker eq 'zurich';
+ $c->forward('gather');
+}
+
+sub gather : Private {
+ my ($self, $c) = @_;
+
+ $c->forward('state'); # Problem/update stats used on that page
+ $c->forward('/admin/fetch_all_bodies'); # For body stat
+
+ my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction );
+
+ my %alert_counts =
+ map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all;
+
+ $alert_counts{0} ||= 0;
+ $alert_counts{1} ||= 0;
+
+ $c->stash->{alerts} = \%alert_counts;
+
+ my $contacts = $c->model('DB::Contact')->summary_count();
+
+ my %contact_counts =
+ map { $_->state => $_->get_column('state_count') } $contacts->all;
+
+ $contact_counts{confirmed} ||= 0;
+ $contact_counts{unconfirmed} ||= 0;
+ $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed};
+
+ $c->stash->{contacts} = \%contact_counts;
+
+ my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction );
+
+ my %questionnaire_counts = map {
+ $_->get_column('answered') => $_->get_column('questionnaire_count')
+ } $questionnaires->all;
+ $questionnaire_counts{1} ||= 0;
+ $questionnaire_counts{0} ||= 0;
+
+ $questionnaire_counts{total} =
+ $questionnaire_counts{0} + $questionnaire_counts{1};
+ $c->stash->{questionnaires_pc} =
+ $questionnaire_counts{total}
+ ? sprintf( '%.1f',
+ $questionnaire_counts{1} / $questionnaire_counts{total} * 100 )
+ : _('n/a');
+ $c->stash->{questionnaires} = \%questionnaire_counts;
}
sub state : Local : Args(0) {
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Templates.pm b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm
new file mode 100644
index 000000000..efff1b488
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm
@@ -0,0 +1,181 @@
+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,
+ group => $_->get_extra_metadata('group') // '',
+ } } @live_contacts;
+ $c->stash->{contacts} = \@all_contacts;
+ $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups;
+
+ # 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
new file mode 100644
index 000000000..428c35073
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm
@@ -0,0 +1,163 @@
+package FixMyStreet::App::Controller::Admin::Triage;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Admin::Triage - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Admin pages for triaging reports.
+
+This allows reports to be triaged before being sent to the council. It works
+by having a set of categories with a send_method of Triage which sets the report
+state to 'for_triage'. Any reports with the state are then show on '/admin/triage'
+which is available to users with the 'triage' permission.
+
+Clicking on reports on this list will then allow a user to change the category of
+the report to one that has an alternative send method, which will trigger the report
+to be resent.
+
+In order for this to work additional work needs to be done to the cobrand to only
+display triageable categories to the user.
+
+=head1 METHODS
+
+=cut
+
+sub auto : Private {
+ my ( $self, $c ) = @_;
+
+ unless ( $c->user->has_body_permission_to('triage') ) {
+ $c->detach('/page_error_403_access_denied', []);
+ }
+}
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ # default sort to oldest
+ unless ( $c->get_param('sort') ) {
+ $c->set_param('sort', 'created-asc');
+ }
+ $c->stash->{body} = $c->forward('/reports/body_find', [ $c->cobrand->council_area ]);
+ $c->forward( 'stash_report_filter_status' );
+ $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]);
+ $c->forward( '/reports/load_and_group_problems' );
+ $c->stash->{page} = 'reports'; # So the map knows to make clickable pins
+
+ if ($c->get_param('ajax')) {
+ my $ajax_template = $c->stash->{ajax_template} || 'reports/_problem-list.html';
+ $c->detach('/reports/ajax', [ $ajax_template ]);
+ }
+
+ my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, {
+ columns => [ 'id', 'category', 'extra' ],
+ distinct => 1,
+ } )->all_sorted;
+ $c->stash->{filter_categories} = \@categories;
+ $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) };
+ my $pins = $c->stash->{pins} || [];
+
+ my %map_params = (
+ latitude => @$pins ? $pins->[0]{latitude} : 0,
+ longitude => @$pins ? $pins->[0]{longitude} : 0,
+ area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ],
+ any_zoom => 1,
+ );
+ FixMyStreet::Map::display_map(
+ $c, %map_params, pins => $pins,
+ );
+}
+
+sub stash_report_filter_status : Private {
+ my ( $self, $c ) = @_;
+ $c->stash->{filter_problem_states} = { 'for triage' => 1 };
+ return 1;
+}
+
+sub setup_categories : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->stash->{problem}->state eq 'for triage' ) {
+ $c->stash->{holding_options} = [ grep { $_->send_method && $_->send_method eq 'Triage' } @{$c->stash->{category_options}} ];
+ $c->stash->{holding_categories} = { map { $_->category => 1 } @{$c->stash->{holding_options}} };
+ $c->stash->{end_options} = [ grep { !$_->send_method || $_->send_method ne 'Triage' } @{$c->stash->{category_options}} ];
+ $c->stash->{end_categories} = { map { $_->category => 1 } @{$c->stash->{end_options}} };
+ delete $c->stash->{categories_hash};
+ my %category_groups = ();
+ for my $category (@{$c->stash->{end_options}}) {
+ my $group = $category->{group} // $category->get_extra_metadata('group') // [''];
+ # this could be an array ref or a string
+ my @groups = ref $group eq 'ARRAY' ? @$group : ($group);
+ push( @{$category_groups{$_}}, $category ) for @groups;
+ }
+ my @category_groups = ();
+ for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) {
+ push @category_groups, { name => $group, categories => $category_groups{$group} };
+ }
+ $c->stash->{end_groups} = \@category_groups;
+ }
+
+ return 1;
+}
+
+sub update : Private {
+ my ($self, $c) = @_;
+
+ my $problem = $c->stash->{problem};
+
+ my $current_category = $problem->category;
+ my $new_category = $c->get_param('category');
+
+ my $changed = $c->forward('/admin/reports/edit_category', [ $problem, 1 ] );
+
+ if ( $changed ) {
+ $c->stash->{problem}->update( { state => 'confirmed' } );
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'triage' ] );
+
+ 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;
+ }
+
+ $extra->{triage_report} = 1;
+ $extra->{holding_category} = $current_category;
+ $extra->{new_category} = $new_category;
+
+ my $timestamp = \'current_timestamp';
+ my $comment = $problem->add_to_comments( {
+ text => "Report triaged from $current_category to $new_category",
+ created => $timestamp,
+ confirmed => $timestamp,
+ user_id => $c->user->id,
+ name => $name,
+ mark_fixed => 0,
+ anonymous => 0,
+ state => 'confirmed',
+ problem_state => $problem->state,
+ extra => $extra,
+ whensent => \'current_timestamp',
+ } );
+
+ my @alerts = FixMyStreet::DB->resultset('Alert')->search( {
+ alert_type => 'new_updates',
+ parameter => $problem->id,
+ confirmed => 1,
+ } );
+
+ for my $alert (@alerts) {
+ my $alerts_sent = FixMyStreet::DB->resultset('AlertSent')->find_or_create( {
+ alert_id => $alert->id,
+ parameter => $comment->id,
+ } );
+ }
+ }
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm
index bcbc808ed..046e19126 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Users.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm
@@ -27,37 +27,69 @@ Admin pages for editing users
sub index :Path : Args(0) {
my ( $self, $c ) = @_;
- $c->detach('add') if $c->req->method eq 'POST'; # Add a user
-
- if (my $search = $c->get_param('search')) {
- $search = $self->trim($search);
- $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...>
- $c->stash->{searched} = $search;
-
- my $isearch = '%' . $search . '%';
- my $search_n = 0;
- $search_n = int($search) if $search =~ /^\d+$/;
-
- my $users = $c->cobrand->users->search(
- {
- -or => [
- email => { ilike => $isearch },
- phone => { ilike => $isearch },
- name => { ilike => $isearch },
- from_body => $search_n,
- ]
+ if ($c->req->method eq 'POST') {
+ my @uids = $c->get_param_list('uid');
+ my @role_ids = $c->get_param_list('roles');
+ my $user_rs = FixMyStreet::DB->resultset("User")->search({ id => \@uids });
+ foreach my $user ($user_rs->all) {
+ $user->admin_user_body_permissions->delete;
+ $user->user_roles->search({
+ role_id => { -not_in => \@role_ids },
+ })->delete;
+ foreach my $role (@role_ids) {
+ $user->user_roles->find_or_create({
+ role_id => $role,
+ });
}
- );
+ }
+ $c->stash->{status_message} = _('Updated!');
+ }
+
+ my $search = $c->get_param('search');
+ my $role = $c->get_param('role');
+ if ($search || $role) {
+ my $users = $c->cobrand->users;
+ my $isearch;
+ if ($search) {
+ $search = $self->trim($search);
+ $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...>
+ $c->stash->{searched} = $search;
+
+ $isearch = '%' . $search . '%';
+ my $search_n = 0;
+ $search_n = int($search) if $search =~ /^\d+$/;
+
+ $users = $users->search(
+ {
+ -or => [
+ email => { ilike => $isearch },
+ phone => { ilike => $isearch },
+ name => { ilike => $isearch },
+ from_body => $search_n,
+ ]
+ }
+ );
+ }
+ if ($role) {
+ $c->stash->{role_selected} = $role;
+ $users = $users->search({
+ role_id => $role,
+ }, {
+ join => 'user_roles',
+ });
+ }
+
my @users = $users->all;
$c->stash->{users} = [ @users ];
- $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]);
+ if ($search) {
+ $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]);
+ }
} else {
$c->forward('/auth/get_csrf_token');
$c->forward('/admin/fetch_all_bodies');
$c->cobrand->call_hook('admin_user_edit_extra_data');
-
# Admin users by default
my $users = $c->cobrand->users->search(
{ from_body => { '!=', undef } },
@@ -67,6 +99,14 @@ sub index :Path : Args(0) {
$c->stash->{users} = \@users;
}
+ my $rs;
+ if ($c->user->is_superuser) {
+ $rs = $c->model('DB::Role')->search_rs({}, { join => 'body', order_by => ['body.name', 'me.name'] });
+ } elsif ($c->user->from_body) {
+ $rs = $c->user->from_body->roles->search_rs({}, { order_by => 'name' });
+ }
+ $c->stash->{roles} = [ $rs->all ];
+
return 1;
}
@@ -113,9 +153,7 @@ sub add : Local : Args(0) {
$c->stash->{field_errors}->{username} = _('User already exists');
}
- return if %{$c->stash->{field_errors}};
-
- my $user = $c->model('DB::User')->create( {
+ my $user = $c->model('DB::User')->new( {
name => $c->get_param('name'),
email => $email ? $email : undef,
email_verified => $email && $email_v ? 1 : 0,
@@ -127,28 +165,48 @@ sub add : Local : Args(0) {
is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0,
} );
$c->stash->{user} = $user;
+
+ return if %{$c->stash->{field_errors}};
+
$c->forward('user_cobrand_extra_fields');
- $user->update;
+ $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->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) );
+ $c->detach('post_edit_redirect', [ $user ]);
}
-sub edit : Path : Args(1) {
- my ( $self, $c, $id ) = @_;
+sub fetch_body_roles : Private {
+ my ($self, $c, $body ) = @_;
- $c->forward('/auth/get_csrf_token');
+ my $roles = $body->roles->search(undef, { order_by => 'name' });
+ unless ($roles) {
+ delete $c->stash->{roles}; # Body doesn't have any roles
+ return;
+ }
+
+ $c->stash->{roles} = [ $roles->all ];
+}
+
+sub user : Chained('/') PathPart('admin/users') : CaptureArgs(1) {
+ my ( $self, $c, $id ) = @_;
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) ) {
@@ -157,11 +215,11 @@ sub edit : Path : Args(1) {
$c->forward('/admin/fetch_all_bodies');
$c->forward('/admin/fetch_body_areas', [ $user->from_body ]) if $user->from_body;
+ $c->forward('fetch_body_roles', [ $user->from_body ]) if $user->from_body;
$c->cobrand->call_hook('admin_user_edit_extra_data');
if ( defined $c->flash->{status_message} ) {
- $c->stash->{status_message} =
- '<p><em>' . $c->flash->{status_message} . '</em></p>';
+ $c->stash->{status_message} = $c->flash->{status_message};
}
$c->forward('/auth/check_csrf_token') if $c->get_param('submit');
@@ -179,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');
@@ -228,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;
@@ -270,26 +317,45 @@ sub edit : Path : Args(1) {
# 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} ) {
$c->forward('/admin/fetch_body_areas', [ $user->from_body ]);
+ $c->forward('fetch_body_roles', [ $user->from_body ]);
}
if (!$user->from_body) {
# Non-staff users aren't allowed any permissions or to be in an area
$user->admin_user_body_permissions->delete;
+ $user->user_roles->delete;
$user->area_ids(undef);
delete $c->stash->{areas};
+ delete $c->stash->{roles};
delete $c->stash->{fetched_areas_body_id};
} elsif ($c->stash->{available_permissions}) {
- my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} };
- my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions;
- $user->admin_user_body_permissions->search({
- body_id => $user->from_body->id,
- permission_type => { '!=' => \@user_permissions },
- })->delete;
- foreach my $permission_type (@user_permissions) {
- $user->user_body_permissions->find_or_create({
+ my %valid_roles = map { $_->id => 1 } @{$c->stash->{roles}};
+ my @role_ids = grep { $valid_roles{$_} } $c->get_param_list('roles');
+ if (@role_ids) {
+ # Roles take precedence over permissions
+ $user->admin_user_body_permissions->delete;
+ $user->user_roles->search({
+ role_id => { -not_in => \@role_ids },
+ })->delete;
+ foreach my $role (@role_ids) {
+ $user->user_roles->find_or_create({
+ role_id => $role,
+ });
+ }
+ } else {
+ $user->user_roles->delete;
+ my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} };
+ my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions;
+ $user->admin_user_body_permissions->search({
body_id => $user->from_body->id,
- permission_type => $permission_type,
- });
+ permission_type => { -not_in => \@user_permissions },
+ })->delete;
+ foreach my $permission_type (@user_permissions) {
+ $user->user_body_permissions->find_or_create({
+ body_id => $user->from_body->id,
+ permission_type => $permission_type,
+ });
+ }
}
}
@@ -299,35 +365,6 @@ sub edit : Path : Args(1) {
$user->area_ids( @area_ids ? \@area_ids : undef );
}
- # Handle 'trusted' flag(s)
- my @trusted_bodies = $c->get_param_list('trusted_bodies');
- if ( $c->user->is_superuser ) {
- $user->user_body_permissions->search({
- body_id => { '!=' => \@trusted_bodies },
- permission_type => 'trusted',
- })->delete;
- foreach my $body_id (@trusted_bodies) {
- $user->user_body_permissions->find_or_create({
- body_id => $body_id,
- permission_type => 'trusted',
- });
- }
- } elsif ( $c->user->from_body ) {
- my %trusted = map { $_ => 1 } @trusted_bodies;
- my $body_id = $c->user->from_body->id;
- if ( $trusted{$body_id} ) {
- $user->user_body_permissions->find_or_create({
- body_id => $body_id,
- permission_type => 'trusted',
- });
- } else {
- $user->user_body_permissions->search({
- body_id => $body_id,
- permission_type => 'trusted',
- })->delete;
- }
- }
-
# Update the categories this user operates in
if ( $user->from_body ) {
$c->stash->{body} = $user->from_body;
@@ -336,14 +373,15 @@ sub edit : Path : Args(1) {
my @live_contact_ids = map { $_->id } @live_contacts;
my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
$user->set_extra_metadata('categories', \@new_contact_ids);
+ } else {
+ $user->unset_extra_metadata('categories');
}
$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!");
- return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) );
+
+ $c->detach('post_edit_redirect', [ $user ]);
}
if ( $user->from_body ) {
@@ -358,8 +396,10 @@ sub edit : Path : Args(1) {
id => $_->id,
category => $_->category,
active => $active_contacts{$_->id},
+ group => $_->get_extra_metadata('group') // '',
} } @live_contacts;
$c->stash->{contacts} = \@all_contacts;
+ $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups;
}
# this goes after in case we've delete any alerts
@@ -370,6 +410,50 @@ 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 ] ) );
+ } else {
+ return $c->res->redirect( $c->uri_for_action( 'admin/users/index' ) );
+ }
+}
+
sub import :Local {
my ( $self, $c, $id ) = @_;
@@ -387,11 +471,9 @@ sub import :Local {
my $csv = Text::CSV->new({ binary => 1});
my $fh = $c->req->upload('csvfile')->fh;
- $csv->getline($fh); # discard the header
- while (my $row = $csv->getline($fh)) {
- my ($name, $email, $from_body, $permissions) = @$row;
- $email = lc Utils::trim_text($email);
- my @permissions = split(/:/, $permissions);
+ $csv->header($fh);
+ while (my $row = $csv->getline_hr($fh)) {
+ my $email = lc Utils::trim_text($row->{email});
my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 });
if ($user->in_storage) {
@@ -399,16 +481,29 @@ sub import :Local {
next;
}
- $user->name($name);
- $user->from_body($from_body || undef);
- $user->update_or_insert;
+ $user->name($row->{name});
+ $user->from_body($row->{from_body} || undef);
+ $user->password($row->{passwordhash}, 1) if $row->{passwordhash};
+ $user->insert;
- my @user_permissions = grep { $available_permissions{$_} } @permissions;
- foreach my $permission_type (@user_permissions) {
- $user->user_body_permissions->find_or_create({
- body_id => $user->from_body->id,
- permission_type => $permission_type,
- });
+ if ($row->{roles}) {
+ my @roles = split(/:/, $row->{roles});
+ foreach my $role (@roles) {
+ $role = FixMyStreet::DB->resultset("Role")->find({
+ body_id => $user->from_body->id,
+ name => $role,
+ }) or next;
+ $user->add_to_roles($role);
+ }
+ } else {
+ my @permissions = split(/:/, $row->{permissions});
+ my @user_permissions = grep { $available_permissions{$_} } @permissions;
+ foreach my $permission_type (@user_permissions) {
+ $user->user_body_permissions->find_or_create({
+ body_id => $user->from_body->id,
+ permission_type => $permission_type,
+ });
+ }
}
push @{$c->stash->{new_users}}, $user;
@@ -497,7 +592,7 @@ sub user_hide_everywhere : Private {
my ( $self, $c, $user ) = @_;
my $problems = $user->problems->search({ state => { '!=' => 'hidden' } });
while (my $problem = $problems->next) {
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
$problem->update({ state => 'hidden' });
}
my $updates = $user->comments->search({ state => { '!=' => 'hidden' } });
@@ -538,6 +633,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.');
}
@@ -565,6 +661,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;
@@ -575,6 +672,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;
@@ -596,6 +694,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');
@@ -625,6 +724,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');
}
@@ -654,6 +754,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/Alert.pm b/perllib/FixMyStreet/App/Controller/Alert.pm
index 1060c080b..a42e7203a 100644
--- a/perllib/FixMyStreet/App/Controller/Alert.pm
+++ b/perllib/FixMyStreet/App/Controller/Alert.pm
@@ -58,12 +58,15 @@ sub subscribe : Path('subscribe') : Args(0) {
$c->detach('rss') if $c->get_param('rss');
+ my $id = $c->get_param('id');
+ $c->forward('/report/load_problem_or_display_error', [ $id ]) if $id;
+
# if it exists then it's been submitted so we should
# go to subscribe email and let it work out the next step
$c->detach('subscribe_email')
if $c->get_param('rznvy') || $c->get_param('alert');
- $c->go('updates') if $c->get_param('id');
+ $c->go('updates') if $id;
# shouldn't get to here but if we have then do something sensible
$c->go('index');
@@ -148,7 +151,7 @@ sub updates : Path('updates') : Args(0) {
$c->forward('/auth/get_csrf_token');
$c->stash->{email} = $c->get_param('rznvy');
- $c->stash->{problem_id} = $c->get_param('id');
+ $c->stash->{email} ||= $c->user->email if $c->user_exists;
}
=head2 confirm
@@ -193,7 +196,7 @@ sub create_alert : Private {
$alert->insert();
}
- if ( $c->user && $c->user->id == $alert->user->id ) {
+ if ( $c->user_exists && ($c->user->id == $alert->user->id || $c->stash->{can_create_for_another})) {
$alert->confirm();
} else {
$alert->confirmed(0);
@@ -211,13 +214,10 @@ Set up the options in the stash required to create a problem update alert
sub set_update_alert_options : Private {
my ( $self, $c ) = @_;
- my $report_id = $c->get_param('id');
- return unless $report_id =~ /^[1-9]\d*$/;
-
my $options = {
user => $c->stash->{alert_user},
alert_type => 'new_updates',
- parameter => $report_id,
+ parameter => $c->stash->{problem}->id,
};
$c->stash->{alert_options} = $options;
@@ -283,7 +283,7 @@ sub send_confirmation_email : Private {
my $user = $c->stash->{alert}->user;
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
my $token = $c->model("DB::Token")->create(
@@ -340,16 +340,16 @@ sub process_user : Private {
my ( $self, $c ) = @_;
if ( $c->user_exists ) {
- $c->stash->{alert_user} = $c->user->obj;
- return;
+ $c->stash->{can_create_for_another} = $c->stash->{problem}
+ && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids);
+ if (!$c->stash->{can_create_for_another}) {
+ $c->stash->{alert_user} = $c->user->obj;
+ return;
+ }
}
- # Extract all the params to a hash to make them easier to work with
- my %params = map { $_ => $c->get_param($_) }
- ( 'rznvy' ); # , 'password_register' );
-
- # cleanup the email address
- my $email = $params{rznvy} ? lc $params{rznvy} : '';
+ my $email = $c->get_param('rznvy');
+ $email = $email ? lc $email : '';
$email =~ s{\s+}{}g;
push @{ $c->stash->{errors} }, _('Please enter a valid email address')
@@ -357,19 +357,6 @@ sub process_user : Private {
my $alert_user = $c->model('DB::User')->find_or_new( { email => $email } );
$c->stash->{alert_user} = $alert_user;
-
-# # The user is trying to sign in. We only care about email from the params.
-# if ( $c->get_param('submit_sign_in') ) {
-# unless ( $c->forward( '/auth/sign_in', [ $email ] ) ) {
-# $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. Please try again.');
-# return 1;
-# }
-# my $user = $c->user->obj;
-# $c->stash->{alert_user} = $user;
-# return 1;
-# }
-#
-# $alert_user->password( $params{password_register} );
}
=head2 setup_coordinate_rss_feeds
diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm
index a09161494..af50f1883 100644
--- a/perllib/FixMyStreet/App/Controller/Around.pm
+++ b/perllib/FixMyStreet/App/Controller/Around.pm
@@ -231,27 +231,37 @@ sub check_and_stash_category : Private {
my $all_areas = $c->stash->{all_areas};
my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all;
my %bodies = map { $_->id => $_ } @bodies;
+ $c->cobrand->call_hook(munge_report_new_bodies => \%bodies); # To match setup_categories_and_bodies in New.pm
+
my @list_of_names = map { $_->name } values %bodies;
my $csv = Text::CSV->new();
$csv->combine(@list_of_names);
+ $c->stash->{around_bodies} = \@bodies;
+ $c->stash->{bodies_ids} = [ map { $_->id } @bodies];
$c->{stash}->{list_of_names_as_string} = $csv->string;
+ my $where = { body_id => [ keys %bodies ], };
+
+ my $cobrand_where = $c->cobrand->call_hook('munge_around_category_where', $where );
+ if ( $cobrand_where ) {
+ $where = $cobrand_where;
+ }
+
my @categories = $c->model('DB::Contact')->not_deleted->search(
- {
- body_id => [ keys %bodies ],
- },
+ $where,
{
columns => [ 'category', 'extra' ],
- order_by => [ 'category' ],
distinct => 1
}
- )->all;
+ )->all_sorted;
$c->stash->{filter_categories} = \@categories;
my %categories_mapped = map { $_->category => 1 } @categories;
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $categories = [ $c->get_param_list('filter_category', 1) ];
my %valid_categories = map { $_ => 1 } grep { $_ && $categories_mapped{$_} } @$categories;
$c->stash->{filter_category} = \%valid_categories;
+ $c->cobrand->call_hook('munge_around_filter_category_list');
}
sub map_features : Private {
@@ -312,6 +322,7 @@ sub ajax : Path('/ajax') {
my %valid_categories = map { $_ => 1 } $c->get_param_list('filter_category', 1);
$c->stash->{filter_category} = \%valid_categories;
+ $c->cobrand->call_hook('munge_around_filter_category_list');
$c->forward('map_features', [ { bbox => $c->stash->{bbox} } ]);
$c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]);
@@ -321,12 +332,14 @@ sub nearby : Path {
my ($self, $c) = @_;
my $states = FixMyStreet::DB::Result::Problem->open_states();
- $c->forward('/report/_nearby_json', [ {
+ my $params = {
latitude => $c->get_param('latitude'),
longitude => $c->get_param('longitude'),
categories => [ $c->get_param('filter_category') || () ],
states => $states,
- } ]);
+ };
+ $c->cobrand->call_hook('around_nearby_filter', $params);
+ $c->forward('/report/_nearby_json', [ $params ]);
}
sub location_closest_address : Path('/ajax/closest') {
@@ -416,7 +429,7 @@ sub lookup_by_ref : Private {
external_id => $ref
];
- my $problems = $c->cobrand->problems->search( $criteria );
+ my $problems = $c->cobrand->problems->search({ non_public => 0, -or => $criteria });
my $count = try {
$problems->count;
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm
index c194045b9..cecfa318c 100644
--- a/perllib/FixMyStreet/App/Controller/Auth.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth.pm
@@ -44,13 +44,12 @@ sub general : Path : Args(0) {
# decide which action to take
$c->detach('code_sign_in') if $clicked_sign_in_by_code || ($data_email && !$data_password);
- if (!$data_username && !$data_password && !$data_email) {
- $c->detach('social/facebook_sign_in') if $c->get_param('facebook_sign_in');
- $c->detach('social/twitter_sign_in') if $c->get_param('twitter_sign_in');
+ if (!$data_username && !$data_password && !$data_email && $c->get_param('social_sign_in')) {
+ $c->forward('social/handle_sign_in');
}
- $c->forward( 'sign_in', [ $data_username ] )
- && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] );
+ $c->forward( 'sign_in', [ $data_username ] )
+ && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] );
}
@@ -68,6 +67,25 @@ sub forgot : Path('forgot') : Args(0) {
$c->detach('code_sign_in');
}
+sub expired : Path('expired') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->detach('/page_error_403_access_denied', []) unless $c->user_exists;
+
+ my $expiry = $c->cobrand->call_hook('password_expiry');
+ $c->detach('/page_error_403_access_denied', []) unless $expiry;
+
+ my $last_change = $c->user->get_extra_metadata('last_password_change') || 0;
+ my $midnight = int(time()/86400)*86400;
+ my $expired = $last_change + $expiry < $midnight;
+ $c->detach('/page_error_403_access_denied', []) unless $expired;
+
+ $c->stash->{expired_password} = 1;
+ $c->stash->{template} = 'auth/create.html';
+ return unless $c->req->method eq 'POST';
+ $c->detach('code_sign_in', [ $c->user->email ]);
+}
+
sub authenticate : Private {
my ($self, $c, $type, $username, $password) = @_;
return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password });
@@ -122,9 +140,9 @@ they come back with a token (which contains the email/phone).
=cut
sub code_sign_in : Private {
- my ( $self, $c ) = @_;
+ my ( $self, $c, $override_username ) = @_;
- my $username = $c->stash->{username} = $c->get_param('username') || '';
+ my $username = $c->stash->{username} = $override_username || $c->get_param('username') || '';
my $parsed = FixMyStreet::SMS->parse_username($username);
@@ -180,10 +198,13 @@ sub email_sign_in : Private {
name => $c->get_param('name'),
password => $user->password,
};
- $token_data->{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $token_data->{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+
+ if ($c->get_param('oauth_need_email')) {
+ $token_data->{name} = $c->session->{oauth}{name}
+ if $c->session->{oauth}{name} && !$token_data->{name};
+ $c->forward('set_oauth_token_data', [ $token_data ]);
+ }
+
if ($c->stash->{current_user}) {
$token_data->{old_user_id} = $c->stash->{current_user}->id;
$token_data->{r} = 'auth/change_email/success';
@@ -214,6 +235,14 @@ sub get_token : Private {
return $data;
}
+sub set_oauth_token_data : Private {
+ my ( $self, $c, $token_data ) = @_;
+
+ foreach (qw/facebook_id twitter_id oidc_id extra logout_redirect_uri change_password_uri/) {
+ $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_};
+ }
+}
+
=head2 token
Handle the 'email_sign_in' tokens. Find the account for the email address
@@ -231,11 +260,11 @@ sub token : Path('/M') : Args(1) {
&& (!$c->user_exists || $c->user->id ne $data->{old_user_id});
my $type = $data->{login_type} || 'email';
- $c->detach( '/auth/process_login', [ $data, $type ] );
+ $c->detach( '/auth/process_login', [ $data, $type, $url_token ] );
}
sub process_login : Private {
- my ( $self, $c, $data, $type ) = @_;
+ my ( $self, $c, $data, $type, $url_token ) = @_;
# sign out in case we are another user
$c->logout();
@@ -247,8 +276,15 @@ sub process_login : Private {
$c->detach( '/page_error_403_access_denied', [] )
if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id};
- # Superusers using 2FA can not log in by code
- $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
+ # People using 2FA need to supply a code
+ my $must_have_2fa = $c->cobrand->call_hook('must_have_2fa', $user) || '';
+ if ($must_have_2fa ne 'skip') {
+ if ($user->has_2fa) {
+ $c->forward( 'token_2fa', [ $user, $url_token ] );
+ } elsif ($c->cobrand->call_hook('must_have_2fa', $user)) {
+ $c->forward( 'signup_2fa', [ $user ] );
+ }
+ }
if ($data->{old_user_id}) {
# Were logged in as old_user_id, want to switch to $user
@@ -272,13 +308,74 @@ sub process_login : Private {
$user->password( $data->{password}, 1 ) if $data->{password};
$user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id};
$user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id};
+ $user->add_oidc_id( $data->{oidc_id} ) if $data->{oidc_id};
+ $user->extra({
+ %{ $user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
+
$user->update_or_insert;
$c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' );
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
+
+
# send the user to their page
$c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] );
}
+=head2 token_2fa
+
+Used after clicking an email token link to request a 2FA code
+
+=cut
+
+sub token_2fa : Private {
+ my ($self, $c, $user, $url_token) = @_;
+
+ return if $c->check_2fa($user->has_2fa);
+
+ $c->stash->{form_action} = $c->req->path;
+ $c->stash->{token} = $url_token;
+ $c->stash->{template} = 'auth/2fa/form.html';
+ $c->detach;
+}
+
+sub signup_2fa : Private {
+ my ($self, $c, $user) = @_;
+
+ $c->stash->{form_action} = $c->req->path;
+ $c->stash->{template} = 'auth/2fa/intro.html';
+ my $action = $c->get_param('2fa_action') || '';
+
+ my $secret;
+ if ($action eq 'confirm') {
+ $secret = $c->get_param('secret32');
+ if ($c->check_2fa($secret)) {
+ $user->set_extra_metadata('2fa_secret' => $secret);
+ $user->update;
+ $c->stash->{stage} = 'success';
+ return;
+ } else {
+ $action = 'activate'; # Incorrect code, reshow
+ }
+ }
+
+ if ($action eq 'activate') {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code($secret, $user->email, $c->cobrand->base_url);
+ $c->stash->{secret32} = $auth->secret32;
+ $c->stash->{stage} = 'activate';
+ }
+
+ $c->detach;
+}
+
=head2 redirect_on_signin
Used after signing in to take the person back to where they were.
@@ -294,8 +391,11 @@ sub redirect_on_signin : Private {
}
unless ( $redirect ) {
- $c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories };
- $redirect = 'my';
+ my $inspector = $c->user->from_body && (
+ scalar @{ $c->user->categories } ||
+ scalar @{ $c->user->area_ids || [] }
+ );
+ $redirect = $inspector ? 'my/inspector_redirect' : 'my';
}
$redirect = 'my' if $redirect =~ /^admin/ && !$c->cobrand->admin_allow_user($c->user);
if ( $c->cobrand->moniker eq 'zurich' ) {
@@ -308,22 +408,6 @@ sub redirect_on_signin : Private {
}
}
-=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 = $c->user->categories_string;
- 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
Used when trying to view a page that requires sign in when you're not.
@@ -429,6 +513,12 @@ Log the user out. Tell them we've done so.
sub sign_out : Local {
my ( $self, $c ) = @_;
$c->logout();
+
+ if ( $c->sessionid && $c->session->{oauth} && $c->session->{oauth}{logout_redirect_uri} ) {
+ $c->response->redirect($c->session->{oauth}{logout_redirect_uri});
+ delete $c->session->{oauth}{logout_redirect_uri};
+ $c->detach;
+ }
}
sub ajax_sign_in : Path('ajax/sign_in') {
@@ -436,7 +526,8 @@ sub ajax_sign_in : Path('ajax/sign_in') {
my $return = {};
if ( $c->forward( 'sign_in', [ $c->get_param('email') ] ) ) {
- $return->{name} = $c->user->name;
+ $return->{name} = $c->user->name || '-'; # App currently requires something returned
+ $return->{success} = 1;
} else {
$return->{error} = 1;
}
@@ -509,6 +600,11 @@ sub check_auth : Local {
return;
}
+sub two_factor_setup_success : Private {
+ my ($self, $c) = @_;
+ # Only here to be detached to after setup success
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
index 87aff2261..a89c6f539 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
@@ -74,7 +74,8 @@ sub change_password : Path('/auth/change_password') {
if ($c->user->password) {
# we should have a usable password - save it to the user
- $c->user->obj->update( { password => $new } );
+ $c->user->obj->password($new);
+ $c->user->obj->update;
$c->stash->{password_changed} = 1;
} else {
# Set up arguments for code sign in
@@ -188,23 +189,38 @@ sub generate_token : Path('/auth/generate_token') {
if ($c->get_param('generate_token')) {
my $token = mySociety::AuthToken::random_token();
$c->user->set_extra_metadata('access_token', $token);
+ $c->user->update;
$c->stash->{token_generated} = 1;
}
- if ($c->get_param('toggle_2fa') && $c->user->is_superuser) {
- if ($has_2fa) {
- $c->user->unset_extra_metadata('2fa_secret');
- $c->stash->{toggle_2fa_off} = 1;
+ my $action = $c->get_param('2fa_action') || '';
+ $action = 'deactivate' if $c->get_param('2fa_deactivate');
+ $action = 'activate' if $c->get_param('2fa_activate');
+ $action = 'activate' if $action eq 'deactivate' && $has_2fa && $c->cobrand->call_hook('must_have_2fa', $c->user);
+
+ my $secret;
+ if ($action eq 'deactivate') {
+ $c->user->unset_extra_metadata('2fa_secret');
+ $c->user->update;
+ $c->stash->{toggle_2fa_off} = 1;
+ } elsif ($action eq 'confirm') {
+ $secret = $c->get_param('secret32');
+ if ($c->check_2fa($secret)) {
+ $c->user->set_extra_metadata('2fa_secret', $secret);
+ $c->user->update;
+ $c->stash->{stage} = 'success';
+ $has_2fa = 1;
} else {
- my $auth = Auth::GoogleAuth->new;
- $c->stash->{qr_code} = $auth->qr_code(undef, $c->user->email, 'FixMyStreet');
- $c->stash->{secret32} = $auth->secret32;
- $c->user->set_extra_metadata('2fa_secret', $auth->secret32);
- $c->stash->{toggle_2fa_on} = 1;
+ $action = 'activate'; # Incorrect code, reshow
}
}
- $c->user->update();
+ if ($action eq 'activate') {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code($secret, $c->user->email, $c->cobrand->base_url);
+ $c->stash->{secret32} = $auth->secret32;
+ $c->stash->{stage} = 'activate';
+ }
}
$c->stash->{has_2fa} = $has_2fa ? 1 : 0;
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm
index 097cac984..06e67573f 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Social.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Social.pm
@@ -6,6 +6,10 @@ BEGIN { extends 'Catalyst::Controller'; }
use Net::Facebook::Oauth2;
use Net::Twitter::Lite::WithAPIv1_1;
+use OIDC::Lite::Client::WebServer::Azure;
+use URI::Escape;
+
+use mySociety::AuthToken;
=head1 NAME
@@ -13,10 +17,26 @@ FixMyStreet::App::Controller::Auth::Social - Catalyst Controller
=head1 DESCRIPTION
-Controller for the Facebook/Twitter authentication.
+Controller for the Facebook/Twitter/OpenID Connect authentication.
=head1 METHODS
+=head2 handle_sign_in
+
+Forwards to the appropriate (facebook|twitter|oidc)_sign_in method
+based on the social_sign_in parameter
+
+=cut
+
+sub handle_sign_in : Private {
+ my ($self, $c) = @_;
+
+ $c->detach('facebook_sign_in') if $c->get_param('social_sign_in') eq 'facebook';
+ $c->detach('twitter_sign_in') if $c->get_param('social_sign_in') eq 'twitter';
+ $c->detach('oidc_sign_in') if $c->get_param('social_sign_in') eq 'oidc';
+
+}
+
=head2 facebook_sign_in
Starts the Facebook authentication sequence.
@@ -142,6 +162,166 @@ sub twitter_callback: Path('/auth/Twitter') : Args(0) {
$c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]);
}
+sub oidc : Private {
+ my ($self, $c) = @_;
+
+ my $config = $c->cobrand->feature('oidc_login');
+
+ OIDC::Lite::Client::WebServer::Azure->new(
+ id => $config->{client_id},
+ secret => $config->{secret},
+ authorize_uri => $config->{auth_uri},
+ access_token_uri => $config->{token_uri},
+ );
+}
+
+sub oidc_sign_in : Private {
+ my ( $self, $c ) = @_;
+
+ $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED');
+ $c->detach( '/page_error_400_bad_request', [] ) unless $c->cobrand->feature('oidc_login');
+
+ my $oidc = $c->forward('oidc');
+ my $nonce = $self->generate_nonce();
+ my $url = $oidc->uri_to_redirect(
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'login',
+ extra => {
+ response_mode => 'form_post',
+ nonce => $nonce,
+ },
+ );
+
+ my %oauth;
+ $oauth{return_url} = $c->get_param('r');
+ $oauth{detach_to} = $c->stash->{detach_to};
+ $oauth{detach_args} = $c->stash->{detach_args};
+ $oauth{nonce} = $nonce;
+
+ # The OIDC endpoint may require a specific URI to be called to log the user
+ # out when they log out of FMS.
+ if ( my $redirect_uri = $c->cobrand->feature('oidc_login')->{logout_uri} ) {
+ $redirect_uri .= "?post_logout_redirect_uri=";
+ $redirect_uri .= URI::Escape::uri_escape( $c->uri_for('/auth/sign_out') );
+ $oauth{logout_redirect_uri} = $redirect_uri;
+ }
+
+ # The OIDC endpoint may provide a specific URI for changing the user's password.
+ if ( my $password_change_uri = $c->cobrand->feature('oidc_login')->{password_change_uri} ) {
+ $oauth{change_password_uri} = $oidc->uri_to_redirect(
+ uri => $password_change_uri,
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'password_change',
+ extra => {
+ response_mode => 'form_post',
+ },
+ );
+ }
+
+ $c->session->{oauth} = \%oauth;
+ $c->res->redirect($url);
+}
+
+sub oidc_callback: Path('/auth/OIDC') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $oidc = $c->forward('oidc');
+
+ if ($c->get_param('error')) {
+ my $error_desc = $c->get_param('error_description');
+ my $password_reset_uri = $c->cobrand->feature('oidc_login')->{password_reset_uri};
+ if ($password_reset_uri && $error_desc =~ /^AADB2C90118:/) {
+ my $url = $oidc->uri_to_redirect(
+ uri => $password_reset_uri,
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'password_reset',
+ extra => {
+ response_mode => 'form_post',
+ },
+ );
+ $c->res->redirect($url);
+ $c->detach;
+ } elsif ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->flash->{flash_message} = _('Password change cancelled.');
+ $c->res->redirect('/my');
+ $c->detach;
+ } else {
+ $c->detach('oauth_failure');
+ }
+ }
+ $c->detach('/page_error_400_bad_request', []) unless $c->get_param('code') && $c->get_param('state');
+
+ # After a password reset on the OIDC endpoint the user isn't properly logged
+ # in, so redirect them to the usual OIDC login process.
+ if ( $c->get_param('state') eq 'password_reset' ) {
+ # The user may have reset their password as part of the sign-in-during-report
+ # process, so preserve their report and redirect them to the right place
+ # if that happened.
+ if ( $c->session->{oauth} ) {
+ $c->stash->{detach_to} = $c->session->{oauth}{detach_to};
+ $c->stash->{detach_args} = $c->session->{oauth}{detach_args};
+ }
+ $c->detach('oidc_sign_in', []);
+ }
+
+ # User may be coming back here after changing their password on the OIDC endpoint
+ if ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->detach('/auth/profile/change_password_success', []);
+ }
+
+ # The only other valid state param is 'login' at this point.
+ $c->detach('/page_error_400_bad_request', []) unless $c->get_param('state') eq 'login';
+
+ my $id_token;
+ eval {
+ $id_token = $oidc->get_access_token(
+ code => $c->get_param('code'),
+ );
+ };
+ if ($@) {
+ (my $message = $@) =~ s/at [^ ]*Auth.pm.*//;
+ $c->detach('/page_error_500_internal_error', [ $message ]);
+ }
+
+ $c->detach('oauth_failure') unless $id_token;
+
+ # sanity check the token audience is us...
+ $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{aud} eq $c->cobrand->feature('oidc_login')->{client_id};
+
+ # check that the nonce matches what we set in the user session
+ $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{nonce} eq $c->session->{oauth}{nonce};
+
+ # Some claims need parsing into a friendlier format
+ # XXX check how much of this is Westminster/Azure-specific
+ my $name = join(" ", $id_token->payload->{given_name}, $id_token->payload->{family_name});
+ my $email = $id_token->payload->{email};
+ # WCC Azure provides a single email address as an array for some reason
+ my $emails = $id_token->payload->{emails};
+ if ($emails && @$emails) {
+ $email = $emails->[0];
+ }
+
+ # There's a chance that a user may have multiple OIDC logins, so build a namespaced uid to prevent collisions
+ my $uid = join(":", $c->cobrand->moniker, $c->cobrand->feature('oidc_login')->{client_id}, $id_token->payload->{sub});
+
+ # The cobrand may want to set values in the user extra field, e.g. a CRM ID
+ # which is passed to Open311 with reports made by this user.
+ my $extra = $c->cobrand->call_hook(oidc_user_extra => $id_token);
+
+ $c->forward('oauth_success', [ 'oidc', $uid, $name, $email, $extra ]);
+}
+
+# Just a wrapper around random_token to make mocking easier.
+sub generate_nonce : Private {
+ my ($self, $c) = @_;
+
+ return mySociety::AuthToken::random_token();
+}
+
+
sub oauth_failure : Private {
my ( $self, $c ) = @_;
@@ -155,30 +335,64 @@ sub oauth_failure : Private {
}
sub oauth_success : Private {
- my ($self, $c, $type, $uid, $name, $email) = @_;
+ my ($self, $c, $type, $uid, $name, $email, $extra) = @_;
my $user;
if ($email) {
- # Only Facebook gets here
+ # Only Facebook & OIDC get here
# We've got an ID and an email address
+
# Remove any existing mention of this ID
- my $existing = $c->model('DB::User')->find( { facebook_id => $uid } );
- $existing->update( { facebook_id => undef } ) if $existing;
- # Get or create a user, give it this Facebook ID
+ my $existing;
+ if ($type eq 'facebook') {
+ $existing = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ $existing->update( { $type . '_id' => undef } ) if $existing;
+ } elsif ( $type eq 'oidc' ) {
+ $existing = $c->model('DB::User')->find( { oidc_ids => \[
+ '&& ?', [ oidc_ids => [ $uid ] ]
+ ] } );
+ $existing->remove_oidc_id( $uid ) if $existing;
+ }
+
+ # Get or create a user, give it this Facebook/OIDC ID
$user = $c->model('DB::User')->find_or_new( { email => $email } );
- $user->facebook_id($uid);
+ if ( $type eq 'facebook' ) {
+ $user->facebook_id($uid);
+ } elsif ( $type eq 'oidc' ) {
+ $user->add_oidc_id($uid);
+ }
$user->name($name);
+ if ($extra) {
+ $user->extra({
+ %{ $user->get_extra() },
+ %$extra
+ });
+ }
$user->in_storage() ? $user->update : $user->insert;
} else {
# We've got an ID, but no email
- $user = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ if ($type eq 'oidc') {
+ $user = $c->model('DB::User')->find( { oidc_ids => \[
+ '&& ?', [ oidc_ids => [ $uid ] ]
+ ] } );
+ } else {
+ $user = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ }
if ($user) {
# Matching ID in our database
$user->name($name);
+ if ($extra) {
+ $user->extra({
+ %{ $user->get_extra() },
+ %$extra
+ });
+ }
$user->update;
} else {
# No matching ID, store ID for use later
$c->session->{oauth}{$type . '_id'} = $uid;
+ $c->session->{oauth}{name} = $name;
+ $c->session->{oauth}{extra} = $extra;
$c->stash->{oauth_need_email} = 1;
}
}
diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm
index fb525fc1f..9ce89a9e2 100644
--- a/perllib/FixMyStreet/App/Controller/Contact.pm
+++ b/perllib/FixMyStreet/App/Controller/Contact.pm
@@ -7,6 +7,7 @@ BEGIN { extends 'Catalyst::Controller'; }
use MIME::Base64;
use mySociety::EmailUtil;
use FixMyStreet::Email;
+use FixMyStreet::Template::SafeString;
=head1 NAME
@@ -26,11 +27,15 @@ Functions to run on both GET and POST contact requests.
sub auto : Private {
my ($self, $c) = @_;
- $c->forward('setup_request');
- $c->forward('determine_contact_type');
$c->forward('/auth/get_csrf_token');
}
+sub begin : Private {
+ my ($self, $c) = @_;
+ $c->forward('/begin');
+ $c->forward('setup_request');
+}
+
=head2 index
Display contact us page
@@ -39,6 +44,7 @@ Display contact us page
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
+ $c->forward('determine_contact_type');
}
=head2 submit
@@ -50,6 +56,7 @@ Handle contact us form submission
sub submit : Path('submit') : Args(0) {
my ( $self, $c ) = @_;
+ $c->forward('determine_contact_type');
$c->res->redirect( '/contact' ) and return unless $c->req->method eq 'POST';
$c->go('index') unless $c->forward('validate');
@@ -87,11 +94,11 @@ sub determine_contact_type : Private {
} elsif ($id) {
$c->forward( '/report/load_problem_or_display_error', [ $id ] );
if ($update_id) {
- my $update = $c->model('DB::Comment')->search(
+ my $update = $c->cobrand->updates->search(
{
- id => $update_id,
+ "me.id" => $update_id,
problem_id => $id,
- state => 'confirmed',
+ "me.state" => 'confirmed',
}
)->first;
@@ -106,7 +113,14 @@ sub determine_contact_type : Private {
$c->stash->{rejecting_report} = 1;
}
} elsif ( $c->cobrand->abuse_reports_only ) {
- $c->detach( '/page_error_404_not_found' );
+ # General enquiries replaces contact form if enabled
+ if ( $c->cobrand->can('setup_general_enquiries_stash') ) {
+ $c->res->redirect( '/contact/enquiry' );
+ $c->detach;
+ return 1;
+ } else {
+ $c->detach( '/page_error_404_not_found' );
+ }
}
return 1;
@@ -185,6 +199,17 @@ sub prepare_params_for_email : Private {
my $base_url = $c->cobrand->base_url();
my $admin_url = $c->cobrand->admin_base_url;
+ my $user = $c->cobrand->users->find( { email => $c->stash->{em} } );
+ if ( $user ) {
+ $c->stash->{user_admin_url} = $admin_url . '/users/' . $user->id;
+ $c->stash->{user_reports_admin_url} = $admin_url . '/reports?search=' . $user->email;
+
+ my $user_latest_problem = $user->latest_visible_problem();
+ if ( $user_latest_problem) {
+ $c->stash->{user_latest_report_admin_url} = $admin_url . '/report_edit/' . $user_latest_problem->id;
+ }
+ }
+
if ( $c->stash->{update} ) {
$c->stash->{problem_url} = $base_url . $c->stash->{update}->url;
@@ -229,8 +254,9 @@ generally required to stash
sub setup_request : Private {
my ( $self, $c ) = @_;
- $c->stash->{contact_email} = $c->cobrand->contact_email;
- $c->stash->{contact_email} =~ s/\@/&#64;/;
+ my $email = $c->cobrand->contact_email;
+ $email =~ s/\@/&#64;/;
+ $c->stash->{contact_email} = FixMyStreet::Template::SafeString->new($email);
for my $param (qw/em subject message/) {
$c->stash->{$param} = $c->get_param($param);
diff --git a/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm
new file mode 100644
index 000000000..5b1c4980f
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm
@@ -0,0 +1,119 @@
+package FixMyStreet::App::Controller::Contact::Enquiry;
+
+use Moose;
+use namespace::autoclean;
+use Path::Tiny;
+use File::Copy;
+use Digest::SHA qw(sha1_hex);
+use File::Basename;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+sub auto : Private {
+ my ($self, $c) = @_;
+
+ unless ( $c->cobrand->call_hook('setup_general_enquiries_stash') ) {
+ $c->res->redirect( '/' );
+ $c->detach;
+ }
+}
+
+# This needs to be defined here so /contact/begin doesn't get run instead.
+sub begin : Private {
+ my ($self, $c) = @_;
+
+ $c->forward('/begin');
+}
+
+sub index : Path : Args(0) {
+ my ( $self, $c, $preserve_session ) = @_;
+
+ # Make sure existing files aren't lost if we're rendering this
+ # page as a result of validation error.
+ delete $c->session->{enquiry_files} unless $preserve_session;
+
+ $c->stash->{field_errors}->{name} = _("Please enter your full name.") if $c->stash->{field_errors}->{name};
+}
+
+sub submit : Path('submit') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ unless ($c->req->method eq 'POST' && $c->forward("/report/new/check_form_submitted") ) {
+ $c->res->redirect( '/contact/enquiry' );
+ return;
+ }
+
+ # General enquiries are always private reports, and aren't
+ # located by the user on the map
+ $c->set_param('non_public', 1);
+ $c->set_param('pc', '');
+ $c->set_param('skipped', 1);
+
+ $c->forward('/report/new/initialize_report');
+ $c->forward('/report/new/check_for_category');
+ $c->forward('/auth/check_csrf_token');
+ $c->forward('/report/new/process_report');
+ $c->forward('/report/new/process_user');
+ $c->forward('handle_uploads');
+ $c->forward('/photo/process_photo');
+ $c->go('index', [ 1 ]) unless $c->forward('/report/new/check_for_errors');
+ $c->forward('/report/new/save_user_and_report');
+ $c->forward('confirm_report');
+ $c->stash->{success} = 1;
+
+ # Don't want these lingering around for the next time.
+ delete $c->session->{enquiry_files};
+}
+
+sub confirm_report : Private {
+ my ( $self, $c ) = @_;
+
+ my $report = $c->stash->{report};
+
+ # We don't ever want to modify an existing user, as general enquiries don't
+ # require any kind of email confirmation.
+ $report->user->insert unless $report->user->in_storage;
+ $report->confirm();
+ $report->update;
+}
+
+sub handle_uploads : Private {
+ my ( $self, $c ) = @_;
+
+ # NB. For simplicity's sake this relies on the UPLOAD_DIR config key provided
+ # when using the FileSystem PHOTO_STORAGE_BACKEND. Should your FMS site not
+ # be using this storage backend, you must ensure that UPLOAD_DIR is set
+ # in order for general enquiries uploads to work.
+ my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS');
+ my $dir = $cfg ? $cfg->{UPLOAD_DIR} : FixMyStreet->config('UPLOAD_DIR');
+ $dir = path($dir, "enquiry_files")->absolute(FixMyStreet->path_to());
+ $dir->mkpath;
+
+ my $files = $c->session->{enquiry_files} || {};
+ foreach ($c->req->upload) {
+ my $upload = $c->req->upload($_);
+ if ($upload->type !~ /^image/) {
+ # It's not a photo so remove it before /photo/process_photo rejects it
+ delete $c->req->uploads->{$_};
+
+ # For each file, copy it into place in a subdir of PHOTO_STORAGE_OPTIONS.UPLOAD_DIR
+ FixMyStreet::PhotoStorage::base64_decode_upload($c, $upload);
+ # Hash each file to get its filename, but preserve the file extension
+ # so content-type is correct when POSTing to Open311.
+ my ($p, $n, $ext) = fileparse($upload->filename, qr/\.[^.]*/);
+ my $key = sha1_hex($upload->slurp) . $ext;
+ my $out = path($dir, $key);
+ unless (copy($upload->tempname, $out)) {
+ $c->log->info('Couldn\'t copy temp file to destination: ' . $!);
+ $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again.");
+ return;
+ }
+ # Then store the file hashes in report->extra along with the original filenames
+ $files->{$key} = $upload->raw_basename;
+ }
+ }
+ $c->session->{enquiry_files} = $files;
+ $c->stash->{report}->set_extra_metadata(enquiry_files => $files);
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm
index bd60f8570..ad6c9ba98 100644
--- a/perllib/FixMyStreet/App/Controller/Dashboard.pm
+++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm
@@ -70,15 +70,20 @@ sub check_page_allowed : Private {
$c->detach( '/auth/redirect' ) unless $c->user_exists;
- $c->detach( '/page_error_404_not_found' )
- unless $c->user->from_body || $c->user->is_superuser;
+ my $cobrand_body = $c->cobrand->can('council_area_id') ? $c->cobrand->body : undef;
- my $body = $c->user->from_body;
- if (!$body && $c->get_param('body')) {
- # Must be a superuser, so allow query parameter if given
- $body = $c->model('DB::Body')->find({ id => $c->get_param('body') });
+ my $body;
+ if ($c->user->is_superuser) {
+ if ($c->get_param('body')) {
+ $body = $c->model('DB::Body')->find({ id => $c->get_param('body') });
+ } else {
+ $body = $cobrand_body;
+ }
+ } elsif ($c->user->from_body && (!$cobrand_body || $cobrand_body->id == $c->user->from_body->id)) {
+ $body = $c->user->from_body;
+ } else {
+ $c->detach( '/page_error_404_not_found' )
}
-
return $body;
}
@@ -104,6 +109,7 @@ sub index : Path : Args(0) {
$c->forward('/admin/fetch_contacts');
$c->stash->{contacts} = [ $c->stash->{contacts}->all ];
+ $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]);
# See if we've had anything from the body dropdowns
$c->stash->{category} = $c->get_param('category');
@@ -322,6 +328,7 @@ sub export_as_csv_updates : Private {
objects => $c->stash->{objects_rs}->search_rs({}, {
order_by => ['me.confirmed', 'me.id'],
'+columns' => ['problem.bodies_str'],
+ cursor_page_size => 1000,
}),
headers => [
'Report ID', 'Update ID', 'Date', 'Status', 'Problem state',
@@ -342,8 +349,10 @@ sub export_as_csv : Private {
my $csv = $c->stash->{csv} = {
objects => $c->stash->{objects_rs}->search_rs({}, {
- prefetch => 'comments',
+ join => 'comments',
+ '+columns' => ['comments.problem_state', 'comments.state', 'comments.confirmed', 'comments.mark_fixed'],
order_by => ['me.confirmed', 'me.id'],
+ cursor_page_size => 1000,
}),
headers => [
'Report ID',
@@ -487,9 +496,6 @@ sub generate_csv : Private {
}
$csv->print($c->response, [
- map {
- $_ = encode('UTF-8', $_) if $_;
- }
@{$hashref}{
@{$c->stash->{csv}->{columns}}
},
@@ -497,6 +503,114 @@ sub generate_csv : Private {
}
}
+sub heatmap : Local : Args(0) {
+ my ($self, $c) = @_;
+
+ my $body = $c->stash->{body} = $c->forward('check_page_allowed');
+ $c->detach( '/page_error_404_not_found' )
+ unless $body && $c->cobrand->feature('heatmap');
+
+ $c->stash->{page} = 'reports'; # So the map knows to make clickable pins
+
+ my @wards = $c->get_param_list('wards', 1);
+ $c->forward('/reports/ward_check', [ @wards ]) if @wards;
+ $c->forward('/reports/stash_report_filter_status');
+ $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]); # Not actually used
+ my $parameters = $c->forward( '/reports/load_problems_parameters');
+
+ my $where = $parameters->{where};
+ my $filter = $parameters->{filter};
+ delete $filter->{rows};
+
+ $c->forward('heatmap_filters', [ $where ]);
+
+ # Load the relevant stuff for the sidebar as well
+ my $problems = $c->cobrand->problems;
+ $problems = $problems->to_body($body);
+ $problems = $problems->search($where, $filter);
+
+ $c->forward('heatmap_sidebar', [ $problems, $where ]);
+
+ if ($c->get_param('ajax')) {
+ my @pins;
+ while ( my $problem = $problems->next ) {
+ push @pins, $problem->pin_data($c, 'reports');
+ }
+ $c->stash->{pins} = \@pins;
+ $c->detach('/reports/ajax', [ 'dashboard/heatmap-list.html' ]);
+ }
+
+ my $children = $c->stash->{body}->first_area_children;
+ $c->stash->{children} = $children;
+ $c->stash->{ward_hash} = { map { $_->{id} => 1 } @{$c->stash->{wards}} } if $c->stash->{wards};
+
+ $c->forward('/reports/setup_categories_and_map');
+}
+
+sub heatmap_filters :Private {
+ my ($self, $c, $where) = @_;
+
+ # Wards
+ if ($c->user_exists) {
+ my @areas = @{$c->user->area_ids || []};
+ # Want to get everything if nothing given in an ajax call
+ if (!$c->stash->{wards} && @areas) {
+ $c->stash->{wards} = [ map { { id => $_ } } @areas ];
+ $where->{areas} = [
+ map { { 'like', '%,' . $_ . ',%' } } @areas
+ ];
+ }
+ }
+
+ # Date range
+ my $start_default = DateTime->today(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(months => 1);
+ $c->stash->{start_date} = $c->get_param('start_date') || $start_default->strftime('%Y-%m-%d');
+ $c->stash->{end_date} = $c->get_param('end_date');
+
+ my $range = FixMyStreet::DateRange->new(
+ start_date => $c->stash->{start_date},
+ start_default => $start_default,
+ end_date => $c->stash->{end_date},
+ formatter => $c->model('DB')->storage->datetime_parser,
+ );
+ $where->{'me.confirmed'} = $range->sql;
+}
+
+sub heatmap_sidebar :Private {
+ my ($self, $c, $problems, $where) = @_;
+
+ $c->stash->{five_newest} = [ $problems->search(undef, {
+ rows => 5,
+ order_by => { -desc => 'confirmed' },
+ })->all ];
+
+ $c->stash->{ten_oldest} = [ $problems->search({
+ 'me.state' => [ FixMyStreet::DB::Result::Problem->open_states() ],
+ }, {
+ rows => 10,
+ order_by => 'lastupdate',
+ })->all ];
+
+ my $params = { map { my $n = $_; s/me\./problem\./; $_ => $where->{$n} } keys %$where };
+ my $body = $c->stash->{body};
+
+ my @user;
+ push @user, $c->user->id if $c->user_exists;
+ push @user, $body->comment_user_id if $body->comment_user_id;
+ $params->{'me.user_id'} = { -not_in => \@user } if @user;
+
+ my @c = $c->model('DB::Comment')->to_body($body)->search({
+ %$params,
+ 'me.state' => 'confirmed',
+ }, {
+ columns => 'problem_id',
+ group_by => 'problem_id',
+ order_by => { -desc => \'max(me.confirmed)' },
+ rows => 5,
+ })->all;
+ $c->stash->{five_commented} = [ map { $_->problem } @c ];
+}
+
=head1 AUTHOR
Matthew Somerville
diff --git a/perllib/FixMyStreet/App/Controller/Develop.pm b/perllib/FixMyStreet/App/Controller/Develop.pm
index ae7122fa1..6a1c10b22 100755
--- a/perllib/FixMyStreet/App/Controller/Develop.pm
+++ b/perllib/FixMyStreet/App/Controller/Develop.pm
@@ -115,12 +115,34 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
}
} elsif ($template eq 'questionnaire') {
$vars->{created} = 'N weeks';
+ } elsif ($template eq 'contact') {
+ $vars->{problem} = $c->model('DB::Problem')->search(undef, { rows => 1 } )->first;
+ $vars->{subject} = 'Please remove my details';
+ $vars->{message} = 'I accidentally put my phone number, address, mothers maiden name, and facebook password in my most recent report!! Please remove it!!';
+ $vars->{form_name} = $c->user->name;
+ $vars->{em} = $c->user->email;
+ $vars->{host} = $c->req->header('HOST');
+ $vars->{ip} = $c->req->address;
+ $vars->{user_agent} = $c->req->user_agent;
+ $vars->{complaint} = sprintf(
+ "Complaint about report %d",
+ $vars->{problem}->id,
+ );
+ $vars->{problem_url} = $c->cobrand->base_url() . '/report/' . $vars->{problem}->id;
+ $vars->{admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $vars->{problem}->id;
+ $vars->{user_admin_url} = $c->cobrand->admin_base_url . '/users/' . $c->user->id;
+ $vars->{user_reports_admin_url} = $c->cobrand->admin_base_url . '/reports?search=' . $c->user->email;
+ my $user_latest_problem = $c->user->latest_visible_problem();
+ if ( $user_latest_problem ) {
+ $vars->{user_latest_report_admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $user_latest_problem->id;
+ }
}
my $email = $c->construct_email("$template.txt", $vars);
# Look through the Email::MIME email for the text/html part, and any inline
# images. Turn the images into data: URIs.
+ my $text = '';
my $html = '';
my %images;
$email->walk_parts(sub {
@@ -130,6 +152,8 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
(my $cid = $part->header('Content-ID')) =~ s/[<>]//g;
(my $ct = $part->content_type) =~ s/;.*//;
$images{$cid} = "$ct;base64," . $part->body_raw;
+ } elsif ($part->content_type =~ m[text/plain]i) {
+ $text = $part->body_str;
} elsif ($part->content_type =~ m[text/html]i) {
$html = $part->body_str;
}
@@ -139,7 +163,12 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
$html =~ s/cid:([^"]*)/data:$images{$1}/g;
}
- $c->response->body($html);
+ if ($c->get_param('text')) {
+ $c->response->header(Content_type => 'text/plain');
+ $c->response->body($text);
+ } else {
+ $c->response->body($html);
+ }
}
=item problem_confirm_previewer
diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm
index 8d5b0b147..416fb942a 100644
--- a/perllib/FixMyStreet/App/Controller/Location.pm
+++ b/perllib/FixMyStreet/App/Controller/Location.pm
@@ -6,6 +6,7 @@ BEGIN {extends 'Catalyst::Controller'; }
use Encode;
use FixMyStreet::Geocode;
+use Try::Tiny;
use Utils;
=head1 NAME
@@ -107,6 +108,25 @@ sub determine_location_from_pc : Private {
# pass errors back to the template
$c->stash->{location_error_pc_lookup} = 1;
$c->stash->{location_error} = $error;
+
+ # Log failure in a log db
+ try {
+ my $dbfile = FixMyStreet->path_to('../data/analytics.sqlite');
+ my $db = DBI->connect("dbi:SQLite:dbname=$dbfile", undef, undef, { PrintError => 0 }) or die "$DBI::errstr\n";
+ my $sth = $db->prepare("INSERT INTO location_searches_with_no_results
+ (datetime, cobrand, geocoder, url, user_input)
+ VALUES (?, ?, ?, ?, ?)") or die $db->errstr . "\n";
+ my $rv = $sth->execute(
+ POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime(time())),
+ $c->cobrand->moniker,
+ $c->cobrand->get_geocoder(),
+ $c->stash->{geocoder_url},
+ $pc,
+ );
+ } catch {
+ $c->log->debug("Unable to log to analytics.sqlite: $_");
+ };
+
return;
}
diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm
index 22869d531..f4143f0b4 100644
--- a/perllib/FixMyStreet/App/Controller/Moderate.pm
+++ b/perllib/FixMyStreet/App/Controller/Moderate.pm
@@ -214,7 +214,7 @@ sub report_moderate_hide : Private {
if ($c->get_param('problem_hide')) {
$problem->update({ state => 'hidden' });
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
$c->res->redirect( '/' ); # Go directly to front-page
$c->detach( 'report_moderate_audit', ['hide'] ); # break chain here.
@@ -263,8 +263,8 @@ sub moderate_boolean : Private {
if ($new != $old) {
if ($thing eq 'photo') {
- $object->$thing($new ? $original : undef);
$object->get_photoset->delete_cached;
+ $object->$thing($new ? $original : undef);
} else {
$object->$thing($new);
}
@@ -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/My.pm b/perllib/FixMyStreet/App/Controller/My.pm
index ed890ad82..3328caac0 100644
--- a/perllib/FixMyStreet/App/Controller/My.pm
+++ b/perllib/FixMyStreet/App/Controller/My.pm
@@ -45,6 +45,34 @@ sub my : Path : Args(0) {
$c->forward('setup_page_data');
}
+
+=head2 inspector_redirect
+
+A convenience redirect to the /reports/ page pre-filtered to the
+inspector's body, areas & categories.
+
+=cut
+
+sub inspector_redirect : Local : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $categories = $c->user->categories_string;
+ my $area_ids = $c->user->area_ids || [];
+ my $body = $c->user->from_body;
+
+ $c->detach('/page_error_404_not_found') unless $body && ($categories || @$area_ids);
+
+ if (@$area_ids) {
+ my $ids_string = join ",", @$area_ids;
+ my $areas = mySociety::MaPit::call('areas', [ $ids_string ]);
+ $c->stash->{wards} = [ values %$areas ];
+ }
+
+ $c->stash->{body} = $body;
+ $c->set_param('filter_category', $categories) if $categories;
+ $c->detach('/reports/redirect_body');
+}
+
sub planned : Local : Args(0) {
my ( $self, $c ) = @_;
@@ -161,11 +189,12 @@ sub setup_page_data : Private {
my @categories = $c->stash->{problems_rs}->search({
state => [ FixMyStreet::DB::Result::Problem->visible_states() ],
}, {
- columns => [ 'category' ],
+ columns => [ 'category', 'bodies_str', 'extra' ],
distinct => 1,
order_by => [ 'category' ],
} )->all;
$c->stash->{filter_categories} = \@categories;
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $pins = $c->stash->{pins};
FixMyStreet::Map::display_map(
diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm
index d50d0d03f..adb3de14d 100644
--- a/perllib/FixMyStreet/App/Controller/Offline.pm
+++ b/perllib/FixMyStreet/App/Controller/Offline.pm
@@ -1,5 +1,9 @@
package FixMyStreet::App::Controller::Offline;
+
+use Image::Size;
+use JSON::MaybeXS;
use Moose;
+use Path::Tiny;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
@@ -10,35 +14,108 @@ FixMyStreet::App::Controller::Offline - Catalyst Controller
=head1 DESCRIPTION
-Offline pages Catalyst Controller.
+Offline pages Catalyst Controller - service worker handling
=head1 METHODS
=cut
-sub have_appcache : Private {
+sub service_worker : Path("/service-worker.js") {
my ($self, $c) = @_;
- return $c->user_exists && $c->user->has_body_permission_to('planned_reports')
- && !($c->user->is_superuser && FixMyStreet->staging_flag('enable_appcache', 0));
+ $c->res->content_type('application/javascript');
}
-sub manifest : Path("/offline/appcache.manifest") {
+sub fallback : Local {
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") {
+sub manifest: Path("/.well-known/manifest.webmanifest") {
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');
+ $c->res->content_type('application/manifest+json');
+
+ my $data = {
+ name => $c->stash->{manifest_theme}->{name},
+ short_name => $c->stash->{manifest_theme}->{short_name},
+ background_color => $c->stash->{manifest_theme}->{background_colour},
+ theme_color => $c->stash->{manifest_theme}->{theme_colour},
+ icons => $c->stash->{manifest_theme}->{icons},
+ lang => $c->stash->{lang_code},
+ display => "minimal-ui",
+ start_url => "/?pwa",
+ scope => "/",
+ };
+ if ($c->cobrand->can('manifest')) {
+ $data = { %$data, %{$c->cobrand->manifest} };
+ }
+
+ my $json = encode_json($data);
+ $c->res->body($json);
+}
+
+sub _stash_manifest_theme : Private {
+ my ($self, $c, $cobrand) = @_;
+
+ $c->stash->{manifest_theme} = $c->forward('_find_manifest_theme', [ $cobrand ]);
+}
+
+sub _find_manifest_theme : Private {
+ my ($self, $c, $cobrand, $ignore_cache_and_defaults) = @_;
+
+ my $key = "manifest_theme:$cobrand";
+ # ignore_cache_and_defaults is only used in the admin, so no harm bypassing cache
+ my $manifest_theme = $ignore_cache_and_defaults ? undef : Memcached::get($key);
+
+ unless ( $manifest_theme ) {
+ my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $cobrand });
+ unless ( $theme ) {
+ $theme = $c->model('DB::ManifestTheme')->new({
+ name => $c->stash->{site_name},
+ short_name => $c->stash->{site_name},
+ background_colour => '#ffffff',
+ theme_colour => '#ffd000',
+ });
+ }
+
+ my @icons;
+ my $uri = '/theme/' . $cobrand;
+ my $theme_path = path(FixMyStreet->path_to('web' . $uri));
+ $theme_path->visit(
+ sub {
+ my ($x, $y, $typ) = Image::Size::imgsize($_->stringify);
+ push @icons, {
+ src => join('/', $uri, $_->basename),
+ sizes => join('x', $x, $y),
+ type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '',
+ };
+ }
+ );
+
+ unless (@icons || $ignore_cache_and_defaults) {
+ push @icons,
+ { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" },
+ { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" };
+ }
+
+ $manifest_theme = {
+ icons => \@icons,
+ background_colour => $theme->background_colour,
+ theme_colour => $theme->theme_colour,
+ name => $theme->name,
+ short_name => $theme->short_name,
+ };
+
+ unless ($ignore_cache_and_defaults) {
+ Memcached::set($key, $manifest_theme);
+ }
}
+
+ return $manifest_theme;
+}
+
+sub _clear_manifest_theme_cache : Private {
+ my ($self, $c, $cobrand ) = @_;
+
+ Memcached::delete("manifest_theme:$cobrand");
}
__PACKAGE__->meta->make_immutable;
diff --git a/perllib/FixMyStreet/App/Controller/Open311.pm b/perllib/FixMyStreet/App/Controller/Open311.pm
index 841330e92..b4b5d5e3a 100644
--- a/perllib/FixMyStreet/App/Controller/Open311.pm
+++ b/perllib/FixMyStreet/App/Controller/Open311.pm
@@ -111,8 +111,6 @@ sub get_discovery : Private {
{
'contact' => ["Send email to $contact_email."],
'changeset' => [$prod_changeset],
- # XXX rewrite to match
- 'key_service' => ["Read access is open to all according to our \u003Ca href='/open_data' target='_blank'\u003Eopen data license\u003C/a\u003E. For write access either: 1. return the 'guid' cookie on each call (unique to each client) or 2. use an api key from a user account which can be generated here: http://seeclickfix.com/register The unversioned url will always point to the latest supported version."],
'max_requests' => [ $c->config->{OPEN311_LIMIT} || 1000 ],
'endpoints' => [
{
@@ -195,9 +193,7 @@ sub get_services : Private {
);
}
$c->forward( 'format_output', [ {
- 'services' => [ {
- 'service' => \@services
- } ]
+ 'services' => \@services
} ] );
}
@@ -291,9 +287,7 @@ sub output_requests : Private {
}
$c->forward( 'format_output', [ {
- 'requests' => [ {
- 'request' => \@problemlist
- } ]
+ service_requests => \@problemlist
} ] );
}
@@ -429,7 +423,21 @@ 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, NoAttr => 1 ) );
+ my $group_tags = {
+ services => 'service',
+ attributes => 'attribute',
+ values => 'value',
+ service_requests => 'request',
+ errors => 'error',
+ service_request_updates => 'request_update',
+ };
+ $c->res->body( XMLout($hashref,
+ KeyAttr => {},
+ GroupTags => $group_tags,
+ SuppressEmpty => undef,
+ RootName => undef,
+ NoAttr => 1,
+ ) );
} else {
$c->detach( 'error', [
sprintf(_('Invalid format %s specified.'), $format)
diff --git a/perllib/FixMyStreet/App/Controller/Open311/Updates.pm b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm
new file mode 100644
index 000000000..105400a8a
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm
@@ -0,0 +1,88 @@
+package FixMyStreet::App::Controller::Open311::Updates;
+
+use utf8;
+use Moose;
+use namespace::autoclean;
+use Open311;
+use Open311::GetServiceRequestUpdates;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Open311::Updates - Catalyst Controller
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=cut
+
+sub receive : Regex('^open311/v2/servicerequestupdates.(xml|json)$') : Args(0) {
+ my ( $self, $c ) = @_;
+ $c->stash->{format} = $c->req->captures->[0];
+
+ $c->detach('bad_request', [ 'POST' ]) unless $c->req->method eq 'POST';
+
+ my $body;
+ if ($c->cobrand->can('council_area_id')) {
+ $body = $c->cobrand->body;
+ } else {
+ $body = $c->model('DB::Body')->find({ id => $c->get_param('jurisdiction_id') });
+ }
+ $c->detach('bad_request', ['jurisdiction_id']) unless $body;
+ my $user = $body->comment_user;
+
+ my $key = $c->get_param('api_key') || '';
+ my $token = $c->cobrand->feature('open311_token') || '';
+ $c->detach('bad_request', [ 'api_key' ]) unless $key && $key eq $token;
+
+ my $request = {
+ media_url => $c->get_param('media_url'),
+ external_status_code => $c->get_param('external_status_code'),
+ };
+ foreach (qw(service_request_id update_id updated_datetime status description)) {
+ $request->{$_} = $c->get_param($_) || $c->detach('bad_request', [ $_ ]);
+ }
+
+ my %open311_conf = (
+ endpoint => $body->endpoint,
+ api_key => $body->api_key,
+ jurisdiction => $body->jurisdiction,
+ extended_statuses => $body->send_extended_statuses,
+ );
+
+ my $cobrand = $body->get_cobrand_handler;
+ $cobrand->call_hook(open311_config_updates => \%open311_conf)
+ if $cobrand;
+
+ my $open311 = Open311->new(%open311_conf);
+ my $updates = Open311::GetServiceRequestUpdates->new(
+ system_user => $user,
+ current_open311 => $open311,
+ current_body => $body,
+ );
+
+ my $p = $updates->find_problem($request);
+ $c->detach('bad_request', [ 'not found' ]) unless $p;
+
+ my $comment = $p->comments->search( { external_id => $request->{update_id} } )->first;
+ $c->detach('bad_request', [ 'already exists' ]) if $comment;
+
+ $comment = $updates->process_update($request, $p);
+
+ my $data = { service_request_updates => { update_id => $comment->id } };
+
+ $c->forward('/open311/format_output', [ $data ]);
+}
+
+sub bad_request : Private {
+ my ($self, $c, $comment) = @_;
+ $c->response->status(400);
+ $c->forward('/open311/format_output', [ { errors => { code => 400, description => "Bad request: $comment" } } ]);
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm
index 7b536a292..3408d5e35 100644
--- a/perllib/FixMyStreet/App/Controller/Photo.pm
+++ b/perllib/FixMyStreet/App/Controller/Photo.pm
@@ -39,10 +39,11 @@ sub during :LocalRegex('^(temp|fulltemp)\.([0-9a-f]{40}\.(?:jpeg|png|gif|tiff))$
$size = $size eq 'temp' ? 'default' : 'full';
my $photo = $photoset->get_image_data(size => $size, default => $c->cobrand->default_photo_resize);
+ $c->stash->{non_public} = 0;
$c->forward( 'output', [ $photo ] );
}
-sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|png|gif|tiff)$') {
+sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp|og))?\.(?:jpeg|png|gif|tiff)$') {
my ( $self, $c ) = @_;
my ( $is_update, $id, $photo_number, $size ) = @{ $c->req->captures };
@@ -51,11 +52,12 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|
my $item;
if ( $is_update ) {
- ($item) = $c->model('DB::Comment')->search( {
- id => $id,
- state => 'confirmed',
- photo => { '!=', undef },
- } );
+ ($item) = $c->cobrand->updates->search( {
+ 'me.id' => $id,
+ 'me.state' => 'confirmed',
+ 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ],
+ 'me.photo' => { '!=', undef },
+ }, { prefetch => 'problem' });
} else {
($item) = $c->cobrand->problems->search( {
id => $id,
@@ -68,6 +70,19 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|
$c->detach( 'no_photo' ) unless $c->cobrand->allow_photo_display($item, $photo_number); # Should only be for reports, not updates
+ my $problem = $is_update ? $item->problem : $item;
+ $c->stash->{non_public} = $problem->non_public;
+
+ if ($c->stash->{non_public}) {
+ my $body_ids = $problem->bodies_str_ids;
+ # Check permission
+ $c->detach('no_photo') unless $c->user_exists;
+ $c->detach('no_photo') unless $c->user->is_superuser
+ || $c->user->id == $problem->user->id
+ || $c->user->has_permission_to('report_inspect', $body_ids)
+ || $c->user->has_permission_to('report_mark_private', $body_ids);
+ }
+
my $photo;
$photo = $item->get_photoset
->get_image_data( num => $photo_number, size => $size, default => $c->cobrand->default_photo_resize )
@@ -80,10 +95,12 @@ sub output : Private {
my ( $self, $c, $photo ) = @_;
# Save to file
- path(FixMyStreet->path_to('web', 'photo', 'c'))->mkpath;
- my $out = FixMyStreet->path_to('web', $c->req->path);
- my $symlink_exists = $photo->{symlink} ? symlink($photo->{symlink}, $out) : undef;
- path($out)->spew_raw($photo->{data}) unless $symlink_exists;
+ if (!FixMyStreet->config('LOGIN_REQUIRED') && !$c->stash->{non_public}) {
+ path(FixMyStreet->path_to('web', 'photo', 'c'))->mkpath;
+ my $out = FixMyStreet->path_to('web', $c->req->path);
+ my $symlink_exists = $photo->{symlink} ? symlink($photo->{symlink}, $out) : undef;
+ path($out)->spew_raw($photo->{data}) unless $symlink_exists;
+ }
$c->res->content_type( $photo->{content_type} );
$c->res->body( $photo->{data} );
diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
index d2b0bf3f4..ab6117ae4 100755
--- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm
+++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
@@ -290,9 +290,9 @@ sub display : Private {
my $problem = $c->stash->{questionnaire}->problem;
- $c->stash->{updates} = [ $c->model('DB::Comment')->search(
- { problem_id => $problem->id, state => 'confirmed' },
- { order_by => 'confirmed' }
+ $c->stash->{updates} = [ $c->cobrand->updates->search(
+ { problem_id => $problem->id, "me.state" => 'confirmed' },
+ { order_by => 'me.confirmed' }
)->all ];
$c->stash->{page} = 'questionnaire';
diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm
index 7f798f4f4..72f96013a 100644
--- a/perllib/FixMyStreet/App/Controller/Report.pm
+++ b/perllib/FixMyStreet/App/Controller/Report.pm
@@ -1,5 +1,6 @@
package FixMyStreet::App::Controller::Report;
+use utf8;
use Moose;
use namespace::autoclean;
use JSON::MaybeXS;
@@ -85,11 +86,15 @@ sub display :PathPart('') :Chained('id') :Args(0) {
$c->forward( 'format_problem_for_display' );
my $permissions = $c->stash->{_permissions} ||= $c->forward( 'check_has_permission_to',
- [ qw/report_inspect report_edit_category report_edit_priority report_mark_private/ ] );
+ [ qw/report_inspect report_edit_category report_edit_priority report_mark_private triage/ ] );
if (any { $_ } values %$permissions) {
$c->stash->{template} = 'report/inspect.html';
$c->forward('inspect');
}
+
+ if ($c->user_exists && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids)) {
+ $c->stash->{email} = $c->user->email;
+ }
}
sub moderate_report :PathPart('moderate') :Chained('id') :Args(0) {
@@ -155,10 +160,20 @@ sub load_problem_or_display_error : Private {
$c->stash->{problem} = $problem;
my $permissions = $c->stash->{_permissions} = $c->forward( 'check_has_permission_to',
[ qw/report_inspect report_edit_category report_edit_priority report_mark_private / ] );
- if ( !$c->user || ($c->user->id != $problem->user->id && !($permissions->{report_inspect} || $permissions->{report_mark_private})) ) {
+
+ # If someone has clicked a unique token link in an email to them
+ my $from_email = $c->sessionid && $c->flash->{alert_to_reporter} && $c->flash->{alert_to_reporter} == $problem->id;
+
+ my $allowed = 0;
+ $allowed = 1 if $from_email;
+ $allowed = 1 if $c->user_exists && $c->user->id == $problem->user->id;
+ $allowed = 1 if $permissions->{report_inspect} || $permissions->{report_mark_private};
+
+ unless ($allowed) {
+ my $url = '/auth?r=report/' . $problem->id;
$c->detach(
'/page_error_403_access_denied',
- [ sprintf(_('That report cannot be viewed on %s.'), $c->stash->{site_name}) ]
+ [ sprintf(_('Sorry, you don’t have permission to do that. If you are the problem reporter, or a member of staff, please <a href="%s">sign in</a> to view this report.'), $url) ]
);
}
}
@@ -181,9 +196,9 @@ sub load_problem_or_display_error : Private {
sub load_updates : Private {
my ( $self, $c ) = @_;
- my $updates = $c->model('DB::Comment')->search(
- { problem_id => $c->stash->{problem}->id, state => 'confirmed' },
- { order_by => [ 'confirmed', 'id' ] }
+ my $updates = $c->cobrand->updates->search(
+ { problem_id => $c->stash->{problem}->id, "me.state" => 'confirmed' },
+ { order_by => [ 'me.confirmed', 'me.id' ] }
);
my $questionnaires_still_open = $c->model('DB::Questionnaire')->search(
@@ -293,7 +308,8 @@ sub format_problem_for_display : Private {
delete $report_hashref->{created};
delete $report_hashref->{confirmed};
- my $content = encode_json(
+ my $json = JSON::MaybeXS->new( convert_blessed => 1, utf8 => 1 );
+ my $content = $json->encode(
{
report => $report_hashref,
updates => $c->cobrand->updates_as_hashref( $problem, $c ),
@@ -354,8 +370,6 @@ sub delete :Chained('id') :Args(0) {
$p->lastupdate( \'current_timestamp' );
$p->update;
- $p->user->update_reputation(-1);
-
$c->model('DB::AdminLog')->create( {
user => $c->user->obj,
admin_user => $c->user->from_body->name,
@@ -372,13 +386,19 @@ 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')) {
- my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}});
+ if ($c->cobrand->can('body')) {
+ my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories(
+ $c->stash->{contacts},
+ body_id => $c->cobrand->body->id
+ );
$c->stash->{priorities_by_category} = $priorities_by_category;
- my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}});
+ my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories(
+ $c->stash->{contacts},
+ body_id => $c->cobrand->body->id
+ );
$c->stash->{templates_by_category} = $templates_by_category;
}
@@ -394,12 +414,18 @@ sub inspect : Private {
$c->stash->{max_detailed_info_length} = $c->cobrand->max_detailed_info_length;
- if ( $c->get_param('save') ) {
+ if ( $c->get_param('triage') ) {
+ $c->forward('/auth/check_csrf_token');
+ $c->forward('/admin/triage/update');
+ my $redirect_uri = $c->uri_for( '/admin/triage' );
+ $c->log->debug( "Redirecting to: " . $redirect_uri );
+ $c->res->redirect( $redirect_uri );
+ }
+ elsif ( $c->get_param('save') ) {
$c->forward('/auth/check_csrf_token');
my $valid = 1;
my $update_text = '';
- my $reputation_change = 0;
my %update_params = ();
if ($permissions->{report_inspect}) {
@@ -435,7 +461,7 @@ sub inspect : Private {
$problem->confirmed( \'current_timestamp' );
}
if ( $problem->state eq 'hidden' ) {
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
}
if ( $problem->state eq 'duplicate') {
if (my $duplicate_of = $c->get_param('duplicate_of')) {
@@ -454,8 +480,6 @@ sub inspect : Private {
$update_params{problem_state} = $problem->state;
my $state = $problem->state;
- $reputation_change = 1 if $c->cobrand->reputation_increment_states->{$state};
- $reputation_change = -1 if $c->cobrand->reputation_decrement_states->{$state};
# If an inspector has changed the state, subscribe them to
# updates
@@ -466,19 +490,14 @@ sub inspect : Private {
};
$c->user->create_alert($problem->id, $options);
}
-
- # If the state has been changed to action scheduled and they've said
- # they want to raise a defect, consider the report to be inspected.
- if ($problem->state eq 'action scheduled' && $c->get_param('raise_defect') && !$problem->get_extra_metadata('inspected')) {
- $update_params{extra} = { 'defect_raised' => 1 };
- $problem->set_extra_metadata( inspected => 1 );
- $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'inspected' ] );
- }
}
$problem->non_public($c->get_param('non_public') ? 1 : 0);
+ if ($problem->non_public) {
+ $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} ||= [];
@@ -486,10 +505,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
@@ -511,22 +531,12 @@ sub inspect : Private {
}
}
- if ($permissions->{report_inspect}) {
- if ( $c->get_param('defect_type') ) {
- $problem->defect_type($problem->defect_types->find($c->get_param('defect_type')));
- } else {
- $problem->defect_type(undef);
- }
- }
-
$c->cobrand->call_hook(report_inspect_update_extra => $problem);
if ($valid) {
- if ( $reputation_change != 0 ) {
- $problem->user->update_reputation($reputation_change);
- }
$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')) {
@@ -590,7 +600,13 @@ sub inspect : Private {
sub map :Chained('id') :Args(0) {
my ($self, $c) = @_;
- my $image = $c->stash->{problem}->static_map;
+ my %params;
+ if ( $c->get_param('inline_duplicate') ) {
+ $params{full_size} = 1;
+ $params{zoom} = 5;
+ }
+
+ my $image = $c->stash->{problem}->static_map(%params);
$c->res->content_type($image->{content_type});
$c->res->body($image->{data});
}
@@ -639,7 +655,7 @@ sub _nearby_json :Private {
my $list_html = $c->render_fragment(
'report/nearby.html',
- { reports => $nearby }
+ { reports => $nearby, inline_maps => $c->get_param("inline_maps") ? 1 : 0 }
);
my $json = { pins => \@pins };
@@ -665,6 +681,33 @@ sub check_has_permission_to : Private {
return \%permissions;
};
+
+sub stash_category_groups : Private {
+ my ( $self, $c, $contacts, $combine_multiple ) = @_;
+
+ my %category_groups = ();
+ for my $category (@$contacts) {
+ my $group = $category->{group} // $category->get_extra_metadata('group') // [''];
+ # this could be an array ref or a string
+ my @groups = ref $group eq 'ARRAY' ? @$group : ($group);
+ if (scalar @groups > 1 && $combine_multiple) {
+ @groups = sort @groups;
+ $category->{group} = \@groups;
+ push( @{$category_groups{_('Multiple Groups')}}, $category );
+ } else {
+ push( @{$category_groups{$_}}, $category ) for @groups;
+ }
+ }
+
+ my @category_groups = ();
+ for my $group ( grep { $_ ne _('Other') && $_ ne _('Multiple Groups') } sort keys %category_groups ) {
+ push @category_groups, { name => $group, categories => $category_groups{$group} };
+ }
+ push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')});
+ push @category_groups, { name => _('Multiple Groups'), categories => $category_groups{_('Multiple Groups')} } if ($category_groups{_('Multiple Groups')});
+ $c->stash->{category_groups} = \@category_groups;
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm
index 8944a9307..fc1a78cd5 100644
--- a/perllib/FixMyStreet/App/Controller/Report/New.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/New.pm
@@ -4,10 +4,10 @@ use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use utf8;
use Encode;
use List::MoreUtils qw(uniq);
use List::Util 'first';
-use POSIX 'strcoll';
use HTML::Entities;
use Path::Class;
use Utils;
@@ -102,18 +102,18 @@ sub report_new : Path : Args(0) {
$c->stash->{template} = "report/new/fill_in_details.html";
$c->forward('setup_categories_and_bodies');
$c->forward('setup_report_extra_fields');
- $c->forward('generate_map');
$c->forward('check_for_category');
+ $c->forward('setup_report_extras');
# deal with the user and report and check both are happy
- return unless $c->forward('check_form_submitted');
+ $c->detach('generate_map') unless $c->forward('check_form_submitted');
$c->forward('/auth/check_csrf_token');
$c->forward('process_report');
$c->forward('process_user');
$c->forward('/photo/process_photo');
- return unless $c->forward('check_for_errors');
+ $c->detach('generate_map') unless $c->forward('check_for_errors');
$c->forward('save_user_and_report');
$c->forward('redirect_or_confirm_creation');
}
@@ -142,6 +142,7 @@ sub report_new_ajax : Path('mobile') : Args(0) {
$c->forward('setup_categories_and_bodies');
$c->forward('setup_report_extra_fields');
+ $c->forward('check_for_category');
$c->forward('process_report');
$c->forward('process_user');
$c->forward('/photo/process_photo');
@@ -157,7 +158,7 @@ sub report_new_ajax : Path('mobile') : Args(0) {
my $report = $c->stash->{report};
if ( $report->confirmed ) {
- $c->forward( 'create_reporter_alert' );
+ $c->forward( 'create_related_things' );
$c->stash->{ json_response } = { success => 1, report => $report->id };
} else {
$c->forward( 'send_problem_confirm_email' );
@@ -201,6 +202,10 @@ sub report_form_ajax : Path('ajax') : Args(0) {
my $extra_titles_list = $c->cobrand->title_list($c->stash->{all_areas});
my @list_of_names = map { $_->name } values %{$c->stash->{bodies}};
+ my %display_names = map {
+ my $name = $_->cobrand_name;
+ ( $_->name ne $name ) ? ( $_->name => $name ) : ();
+ } values %{$c->stash->{bodies}};
my $contribute_as = {};
if ($c->user_exists) {
my @bodies = keys %{$c->stash->{bodies}};
@@ -227,6 +232,7 @@ sub report_form_ajax : Path('ajax') : Args(0) {
category => $category,
extra_name_info => $extra_name_info,
titles_list => $extra_titles_list,
+ %display_names ? (display_names => \%display_names) : (),
%$contribute_as ? (contribute_as => $contribute_as) : (),
$top_message ? (top_message => $top_message) : (),
unresponsive => $c->stash->{unresponsive}->{ALL} || '',
@@ -247,42 +253,54 @@ sub category_extras_ajax : Path('category_extras') : Args(0) {
$c->forward('setup_report_extra_fields');
$c->forward('check_for_category');
- my $category = $c->stash->{category} || "";
- $category = '' if $category eq _('-- Pick a category --');
-
- $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $category ]);
+ $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $c->stash->{category} ]);
$c->forward('send_json_response');
}
sub by_category_ajax_data : Private {
my ($self, $c, $type, $category) = @_;
- my $generate;
- if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or
- $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) {
- $generate = 1;
+ my @bodies;
+ my $bodies = [];
+ my $vars = {};
+ if ($category) {
+ $bodies = $c->forward('contacts_to_bodies', [ $category ]);
+ @bodies = @$bodies;
+ $vars->{list_of_names} = [ map { $_->cobrand_name } @bodies ];
+ } else {
+ @bodies = values %{$c->stash->{bodies_to_list}};
}
- my $bodies = $c->forward('contacts_to_bodies', [ $category ]);
- my $list_of_names = [ map { $_->name } ($category ? @$bodies : values %{$c->stash->{bodies_to_list}}) ];
- my $vars = {
- $category ? (list_of_names => $list_of_names) : (),
- };
-
+ my $non_public = $c->stash->{non_public_categories}->{$category};
+ my $anon_button = ($c->cobrand->allow_anonymous_reports($category) eq 'button');
my $body = {
- bodies => $list_of_names,
+ bodies => [ map { $_->name } @bodies ],
+ $non_public ? ( non_public => JSON->true ) : (),
+ $anon_button ? ( allow_anonymous => JSON->true ) : (),
};
- if ($generate) {
+ if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) {
+ my $disable_form = $c->forward('disable_form_message');
+ $body->{disable_form} = $disable_form if %$disable_form;
+
+ # Remove the full disable_form extras, as included in disable form output
+ @{$c->stash->{category_extras}->{$c->stash->{category}}} = grep {
+ !$_->{disable_form} || $_->{disable_form} ne 'true'
+ } @{$c->stash->{category_extras}->{$c->stash->{category}}};
+ }
+
+ if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or
+ $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) {
$body->{category_extra} = $c->render_fragment('report/new/category_extras.html', $vars);
$body->{category_extra_json} = $c->forward('generate_category_extra_json');
-
}
my $unresponsive = $c->stash->{unresponsive}->{$category};
$unresponsive ||= $c->stash->{unresponsive}->{ALL} || '' if $type eq 'one';
# unresponsive must return empty string if okay, as that's what mobile app checks
+ # councils_text.html must be rendered if it differs from the default output,
+ # which currently means for unresponsive and non_public categories.
if ($type eq 'one' || ($type eq 'all' && $unresponsive)) {
$body->{unresponsive} = $unresponsive;
# Check for no bodies here, because if there are any (say one
@@ -292,10 +310,41 @@ sub by_category_ajax_data : Private {
$body->{councils_text_private} = $c->render_fragment( 'report/new/councils_text_private.html');
}
}
+ if ($non_public) {
+ $body->{councils_text} = $c->render_fragment( 'report/new/councils_text.html', $vars);
+ }
return $body;
}
+sub disable_form_message : Private {
+ my ( $self, $c ) = @_;
+
+ my %out;
+
+ # do not set disable form message if they are a staff user
+ return \%out if $c->cobrand->call_hook('staff_ignore_form_disable_form');
+
+ foreach (@{$c->stash->{category_extras}->{$c->stash->{category}}}) {
+ if ($_->{disable_form} && $_->{disable_form} eq 'true') {
+ $out{all} .= ' ' if $out{all};
+ $out{all} .= $_->{description};
+ } elsif (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) {
+ my %category;
+ foreach my $opt (@{$_->{values}}) {
+ if ($opt->{disable}) {
+ $category{message} = $opt->{disable_message} || $_->{datatype_description};
+ $category{code} = $_->{code};
+ push @{$category{answers}}, $opt->{key};
+ }
+ }
+ push @{$out{questions}}, \%category if %category;
+ }
+ }
+
+ return \%out;
+}
+
=head2 report_import
Action to accept report creations from iPhones and other mobile apps. URL is
@@ -309,8 +358,7 @@ sub report_import : Path('/import') {
# If this is not a POST then just print out instructions for using page
return unless $c->req->method eq 'POST';
- # anything else we return is plain text
- $c->res->content_type('text/plain; charset=utf-8');
+ my $format = $c->get_param('web') ? 'web' : 'text';
my %input =
map { $_ => $c->get_param($_) || '' } (
@@ -363,8 +411,14 @@ sub report_import : Path('/import') {
# if we have errors then we should bail out
if (@errors) {
- my $body = join '', map { "ERROR:$_\n" } @errors;
- $c->res->body($body);
+ if ($format eq 'web') {
+ $c->stash->{input} = \%input;
+ $c->stash->{errors} = \@errors;
+ } else {
+ my $body = join '', map { "ERROR:$_\n" } @errors;
+ $c->res->content_type('text/plain; charset=utf-8');
+ $c->res->body($body);
+ }
return;
}
@@ -420,13 +474,13 @@ sub report_import : Path('/import') {
$c->send_email( 'partial.txt', { to => $report->user->email, } );
- if ( $c->get_param('web') ) {
- $c->res->content_type('text/html; charset=utf-8');
+ if ($format eq 'web') {
$c->stash->{template} = 'email_sent.html';
$c->stash->{email_type} = 'problem';
- return 1;
+ } else {
+ $c->res->content_type('text/plain; charset=utf-8');
+ $c->res->body('SUCCESS');
}
- $c->res->body('SUCCESS');
return 1;
}
@@ -476,9 +530,11 @@ sub initialize_report : Private {
->first;
if ($report) {
- # log the problem creation user in to the site
- $c->authenticate( { email => $report->user->email, email_verified => 1 },
- 'no_password' );
+ # log the problem creation user in to the site, if not already logged in
+ if (!$c->user_exists || $c->user->email ne $report->user->email) {
+ $c->authenticate( { email => $report->user->email, email_verified => 1 },
+ 'no_password' );
+ }
# save the token to delete at the end
$c->stash->{partial_token} = $token if $report;
@@ -637,12 +693,12 @@ sub setup_categories_and_bodies : Private {
my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all;
my %bodies = map { $_->id => $_ } @bodies;
- my $contacts #
- = $c #
- ->model('DB::Contact') #
- ->active
- ->search( { 'me.body_id' => [ keys %bodies ] }, { prefetch => 'body' } );
- my @contacts = $c->cobrand->categories_restriction($contacts)->all;
+ $c->cobrand->call_hook(munge_report_new_bodies => \%bodies);
+
+ my $contacts = $c->model('DB::Contact')->for_new_reports($c, \%bodies);
+ my @contacts = $c->cobrand->categories_restriction($contacts)->all_sorted;
+
+ $c->cobrand->call_hook(munge_report_new_contacts => \@contacts);
# variables to populate
my %bodies_to_list = (); # Bodies with categories assigned
@@ -650,6 +706,8 @@ sub setup_categories_and_bodies : Private {
my %category_extras = (); # extra fields to fill in for open311
my %category_extras_hidden =
(); # whether all of a category's fields are hidden
+ my %category_extras_notices =
+ (); # whether all of a category's fields are simple notices and not inputs
my %non_public_categories =
(); # categories for which the reports are not public
$c->stash->{unresponsive} = {};
@@ -668,15 +726,6 @@ sub setup_categories_and_bodies : Private {
$c->stash->{unresponsive}{$k} = { map { $_ => 1 } keys %bodies };
}
- # keysort does not appear to obey locale so use strcoll (see i18n.t)
- @contacts = sort { strcoll( $a->category, $b->category ) } @contacts;
-
- # Get defect types for inspectors
- if ($c->cobrand->can('council_area_id')) {
- my $category_defect_types = FixMyStreet::App->model('DB::DefectType')->by_categories($c->cobrand->council_area_id, @contacts);
- $c->stash->{category_defect_types} = $category_defect_types;
- }
-
my %seen;
foreach my $contact (@contacts) {
@@ -691,6 +740,16 @@ sub setup_categories_and_bodies : Private {
} else {
$category_extras_hidden{$contact->category} = $all_hidden;
}
+
+ my $all_notices = (grep {
+ ( $_->{variable} || '' ) ne 'false'
+ && !$c->cobrand->category_extra_hidden($_)
+ } @$metas) ? 0 : 1;
+ if (exists($category_extras_notices{$contact->category})) {
+ $category_extras_notices{$contact->category} &&= $all_notices;
+ } else {
+ $category_extras_notices{$contact->category} = $all_notices;
+ }
}
$non_public_categories{ $contact->category } = 1 if $contact->non_public;
@@ -712,15 +771,17 @@ sub setup_categories_and_bodies : Private {
push @category_options, $seen{_('Other')} if $seen{_('Other')};
}
- $c->cobrand->call_hook(munge_category_list => \@category_options, \@contacts, \%category_extras);
+ $c->cobrand->call_hook(munge_report_new_category_list => \@category_options, \@contacts, \%category_extras);
# put results onto stash for display
$c->stash->{bodies} = \%bodies;
$c->stash->{contacts} = \@contacts;
$c->stash->{bodies_to_list} = \%bodies_to_list;
+ $c->stash->{bodies_ids} = [ map { $_ } keys %bodies ];
$c->stash->{category_options} = \@category_options;
$c->stash->{category_extras} = \%category_extras;
$c->stash->{category_extras_hidden} = \%category_extras_hidden;
+ $c->stash->{category_extras_notices} = \%category_extras_notices;
$c->stash->{non_public_categories} = \%non_public_categories;
$c->stash->{extra_name_info} = $first_area->{id} == COUNCIL_ID_BROMLEY ? 1 : 0;
@@ -736,20 +797,7 @@ sub setup_categories_and_bodies : Private {
$c->stash->{missing_details_bodies} = \@missing_details_bodies;
$c->stash->{missing_details_body_names} = \@missing_details_body_names;
- if ( $c->cobrand->call_hook('enable_category_groups') ) {
- my %category_groups = ();
- for my $category (@category_options) {
- my $group = $category->{group} // $category->get_extra_metadata('group') // '';
- push @{$category_groups{$group}}, $category;
- }
-
- my @category_groups = ();
- for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) {
- push @category_groups, { name => $group, categories => $category_groups{$group} };
- }
- push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')});
- $c->stash->{category_groups} = \@category_groups;
- }
+ $c->forward('/report/stash_category_groups', [ \@category_options ]) if $c->cobrand->enable_category_groups;
}
sub setup_report_extra_fields : Private {
@@ -794,10 +842,17 @@ sub process_user : Private {
# Report form includes two username fields: #form_username_register and #form_username_sign_in
$params{username} = (first { $_ } $c->get_param_list('username')) || '';
- if ( $c->cobrand->allow_anonymous_reports ) {
+ my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously');
+ my $anon_fallback = $c->cobrand->allow_anonymous_reports eq '1' && !$c->user_exists && !$params{username};
+ if ($anon_button || $anon_fallback) {
my $anon_details = $c->cobrand->anonymous_account;
- $params{username} ||= $anon_details->{email};
- $params{name} ||= $anon_details->{name};
+ my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} });
+ $user->name($anon_details->{name});
+ $report->user($user);
+ $report->name($user->name);
+ $c->stash->{no_reporter_alert} = 1;
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ return 1;
}
# The user is already signed in. Extra bare block for 'last'.
@@ -805,9 +860,11 @@ sub process_user : Private {
my $user = $c->user->obj;
if ($c->stash->{contributing_as_another_user}) {
- # Act as if not logged in (and it will be auto-confirmed later on)
- $report->user(undef);
- last;
+ if ($params{username} || $params{phone}) {
+ # Act as if not logged in (and it will be auto-confirmed later on)
+ $report->user(undef);
+ last;
+ }
}
$report->user( $user );
@@ -821,6 +878,8 @@ sub process_user : Private {
$report->name($name);
$user->name($name) unless $user->name;
$c->stash->{no_reporter_alert} = 1;
+ } elsif ($c->stash->{contributing_as_another_user}) {
+ $c->stash->{no_reporter_alert} = 1;
}
return 1;
@@ -854,7 +913,7 @@ sub process_user : Private {
oauth_report => { $report->get_inflated_columns }
};
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
- $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
+ $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.');
return 1;
}
my $user = $c->user->obj;
@@ -907,12 +966,12 @@ sub process_report : Private {
'title', 'detail', 'pc', #
'detail_size',
'may_show_name', #
- 'category', #
'subcategory', #
'partial', #
'service', #
'non_public',
);
+ $params{category} = $c->stash->{category};
# load the report
my $report = $c->stash->{report};
@@ -929,6 +988,13 @@ sub process_report : Private {
$c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies});
$c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies});
}
+ # This is also done in process_user, but is needed here for anonymous() just below
+ my $anon_button = $c->cobrand->allow_anonymous_reports($params{category}) eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ $c->stash->{contributing_as_body} = undef;
+ $c->stash->{contributing_as_another_user} = undef;
+ }
# set some simple bool values (note they get inverted)
if ($c->stash->{contributing_as_body}) {
@@ -971,12 +1037,20 @@ sub process_report : Private {
}
# check that we've not indicated we only want to sent to a single body
- # and if we find a matching one then only send to that. e.g. if we clicked
- # on a TfL road on the map.
+ # and if we find a matching one then only send to that.
my $body_string = do {
if (my $single_body_only = $c->get_param('single_body_only')) {
my $body = $c->model('DB::Body')->search({ name => $single_body_only })->first;
- $body ? $body->id : '-1';
+ if ($body) {
+ # Drop the contacts down to those in this body
+ # (potentially none for e.g. Highways England)
+ # so that set_report_extras doesn't error when
+ # there are 'missing' extra fields
+ @contacts = grep { $_->body->id == $body->id } @contacts;
+ $body->id;
+ } else {
+ '-1';
+ }
} else {
my $contact_options = {};
$contact_options->{do_not_send} = [ $c->get_param_list('do_not_send', 1) ];
@@ -1065,18 +1139,26 @@ sub contacts_to_bodies : Private {
[ map { $_->body } @contacts ];
}
+sub setup_report_extras : Private {
+ my ($self, $c) = @_;
+
+ # report_meta is used by the templates to fill in the extra field values
+ my $extra = $c->stash->{report}->get_extra_fields;
+ $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @$extra };
+}
+
sub set_report_extras : Private {
my ($self, $c, $contacts, $param_prefix) = @_;
$param_prefix ||= "";
- my @metalist = map { [ $_->get_metadata_for_input, $param_prefix ] } @$contacts;
+ my @metalist = map { [ $_->get_metadata_for_storage, $param_prefix ] } @$contacts;
push @metalist, map { [ $_->get_extra_fields, "extra[" . $_->id . "]" ] } @{$c->stash->{report_extra_fields}};
my @extra;
foreach my $item (@metalist) {
my ($metas, $param_prefix) = @$item;
foreach my $field ( @$metas ) {
- if ( lc( $field->{required} ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) {
+ if ( lc( $field->{required} || '' ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) {
unless ( $c->get_param($param_prefix . $field->{code}) ) {
$c->stash->{field_errors}->{ 'x' . $field->{code} } = _('This information is required');
}
@@ -1112,12 +1194,13 @@ sub check_for_errors : Private {
$c->stash->{field_errors} ||= {};
my %field_errors = $c->cobrand->report_check_for_errors( $c );
+ my $report = $c->stash->{report};
+
# Zurich, we don't care about title or name
# There is no title, and name is optional
if ( $c->cobrand->moniker eq 'zurich' ) {
delete $field_errors{title};
delete $field_errors{name};
- my $report = $c->stash->{report};
$report->title( Utils::cleanup_text( substr($report->detail, 0, 25) ) );
# We only want to validate the phone number web requests (where the
@@ -1137,8 +1220,13 @@ sub check_for_errors : Private {
delete $field_errors{name};
}
+ # If we're making an anonymous report, we do not care about the name field
+ if ( $c->stash->{contributing_as_anonymous_user} ) {
+ delete $field_errors{name};
+ }
+
# if using social login then we don't care about other errors
- $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in');
+ $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0;
if ( $c->stash->{is_social_user} ) {
delete $field_errors{name};
delete $field_errors{username};
@@ -1162,9 +1250,8 @@ sub check_for_errors : Private {
if ( $c->cobrand->allow_anonymous_reports ) {
my $anon_details = $c->cobrand->anonymous_account;
- my $report = $c->stash->{report};
$report->user->email(undef) if $report->user->email eq $anon_details->{email};
- $report->name(undef) if $report->name eq $anon_details->{name};
+ $report->name(undef) if $report->name && $report->name eq $anon_details->{name};
}
return;
@@ -1180,10 +1267,8 @@ sub tokenize_user : Private {
password => $report->user->password,
title => $report->user->title,
};
- $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+ $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ])
+ if $c->get_param('oauth_need_email');
}
sub send_problem_confirm_email : Private {
@@ -1278,7 +1363,7 @@ sub process_confirmation : Private {
);
# Subscribe problem reporter to email updates
- $c->forward( '/report/new/create_reporter_alert' );
+ $c->forward( '/report/new/create_related_things' );
# log the problem creation user in to the site
if ( $data->{name} || $data->{password} ) {
@@ -1291,7 +1376,21 @@ sub process_confirmation : Private {
for (qw(name title facebook_id twitter_id)) {
$problem->user->$_( $data->{$_} ) if $data->{$_};
}
+ $problem->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id};
+ $problem->user->extra({
+ %{ $problem->user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
+
$problem->user->update;
+
+ # Make sure extra oauth state is restored, if applicable
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
}
if ($problem->user->email_verified) {
$c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' );
@@ -1351,11 +1450,7 @@ sub save_user_and_report : Private {
$c->stash->{detach_to} = '/report/new/oauth_callback';
$c->stash->{detach_args} = [$token->token];
- if ( $c->get_param('facebook_sign_in') ) {
- $c->detach('/auth/social/facebook_sign_in');
- } elsif ( $c->get_param('twitter_sign_in') ) {
- $c->detach('/auth/social/twitter_sign_in');
- }
+ $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in');
}
# Save or update the user if appropriate
@@ -1373,6 +1468,13 @@ sub save_user_and_report : Private {
$report->confirm();
} elsif ($c->stash->{contributing_as_anonymous_user}) {
$report->set_extra_metadata( contributed_as => 'anonymous_user' );
+ if ( $c->user_exists && $c->user->from_body ) {
+ # If a staff user has clicked the 'report anonymously' button then
+ # there would be no record of who that staff member was as we've
+ # used the cobrand's anonymous_account for the report. In this case
+ # record the staff user ID in the report metadata.
+ $report->set_extra_metadata( contributed_by => $c->user->id );
+ }
$report->confirm();
} elsif ( !$report->user->in_storage ) {
# User does not exist.
@@ -1450,9 +1552,61 @@ sub generate_map : Private {
sub check_for_category : Private {
my ( $self, $c ) = @_;
- $c->stash->{category} = $c->get_param('category');
+ my $category = $c->get_param('category') || $c->stash->{report}->category || '';
+ $category = '' if $category eq _('Loading...') || $category eq _('-- Pick a category --');
+ # Just check to see if the filter had an option
+ $category ||= $c->get_param('filter_category') || '';
+ $c->stash->{category} = $category;
- return 1;
+ # Bit of a copy of set_report_extras, because we need the results here, but
+ # don't want to run all of that fn until later as it e.g. alters field
+ # errors at that point. Also, the report might already have some answers in
+ # too if e.g. gone via social login... TODO Improve this?
+ my $extra = $c->stash->{report}->get_extra_fields;
+ my %current = map { $_->{name} => $_ } @$extra;
+
+ my @contacts = grep { $_->category eq $category } @{$c->stash->{contacts}};
+ my @metalist = map { @{$_->get_metadata_for_storage} } @contacts;
+ my @extra;
+ foreach my $field (@metalist) {
+ push @extra, {
+ name => $field->{code},
+ description => $field->{description},
+ value => $c->get_param($field->{code}) || $current{$field->{code}}{value} || '',
+ };
+ }
+ $c->stash->{report}->set_extra_fields( @extra );
+
+ # Work out if the selected category (or category extra question answer) should lead
+ # to a message being shown not to use the form
+ if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) {
+ my $disable_form_messages = $c->forward('disable_form_message');
+ if ($disable_form_messages->{all}) {
+ $c->stash->{disable_form_message} = $disable_form_messages->{all};
+ } elsif (my $questions = $disable_form_messages->{questions}) {
+ foreach my $question (@$questions) {
+ my $answer = $c->get_param($question->{code});
+ my $message = $question->{message};
+ if ($answer) {
+ foreach (@{$question->{answers}}) {
+ if ($answer eq $_) {
+ $c->stash->{disable_form_message} = $message;
+ }
+ }
+ }
+ }
+ if (!$c->stash->{disable_form_message}) {
+ $c->stash->{have_disable_qn_to_answer} = 1;
+ }
+ }
+ }
+
+ if ($c->get_param('submit_category_part_only') || $c->stash->{disable_form_message}) {
+ # If we've clicked the first-part category button (no-JS only probably),
+ # or the category submitted will be showing a disabled form message,
+ # we only want to reshow the form
+ $c->stash->{force_form_not_submitted} = 1;
+ }
}
=head2 redirect_or_confirm_creation
@@ -1469,8 +1623,9 @@ sub redirect_or_confirm_creation : Private {
# If confirmed send the user straight there.
if ( $report->confirmed ) {
# Subscribe problem reporter to email updates
- $c->forward( 'create_reporter_alert' );
+ $c->forward( 'create_related_things' );
if ($c->stash->{contributing_as_another_user} && $report->user->email
+ && $report->user->id != $c->user->id
&& !$c->cobrand->report_sent_confirmation_email) {
$c->send_email( 'other-reported.txt', {
to => [ [ $report->user->email, $report->name ] ],
@@ -1491,7 +1646,7 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $report->user->has_2fa;
# otherwise email or text a confirm token to them.
@@ -1510,12 +1665,51 @@ sub redirect_or_confirm_creation : Private {
$c->log->info($report->user->id . ' created ' . $report->id . ", $thing sent, " . ($c->stash->{token_data}->{password} ? 'password set' : 'password not set'));
}
-sub create_reporter_alert : Private {
+sub create_related_things : Private {
my ( $self, $c ) = @_;
+ my $problem = $c->stash->{report};
+
+ # If there is a special template, create a comment using that
+ foreach my $body (values %{$problem->bodies}) {
+ my $user = $body->comment_user or next;
+
+ my %open311_conf = (
+ endpoint => $body->endpoint || '',
+ api_key => $body->api_key || '',
+ jurisdiction => $body->jurisdiction || '',
+ extended_statuses => $body->send_extended_statuses,
+ );
+
+ my $cobrand = $body->get_cobrand_handler;
+ $cobrand->call_hook(open311_config_updates => \%open311_conf)
+ if $cobrand;
+
+ my $open311 = Open311->new(%open311_conf);
+ my $updates = Open311::GetServiceRequestUpdates->new(
+ system_user => $user,
+ current_open311 => $open311,
+ current_body => $body,
+ blank_updates_permitted => 1,
+ );
+
+ my $description = $updates->comment_text_for_request({}, $problem, 'confirmed', 'dummy', '', '');
+ next unless $description;
+
+ my $request = {
+ service_request_id => $problem->id,
+ update_id => 'auto-internal',
+ comment_time => DateTime->now,
+ status => 'open',
+ description => $description,
+ };
+ $updates->process_update($request, $problem);
+ }
+
+ # And now the reporter alert
return if $c->stash->{no_reporter_alert};
+ return if $c->cobrand->call_hook('suppress_reporter_alerts');
- my $problem = $c->stash->{report};
my $alert = $c->model('DB::Alert')->find_or_create( {
user => $problem->user,
alert_type => 'new_updates',
@@ -1576,12 +1770,24 @@ sub generate_category_extra_json : Private {
my $false = JSON->false;
my @fields = map {
- {
- %$_,
- required => $_->{required} eq "true" ? $true : $false,
- variable => $_->{variable} eq "true" ? $true : $false,
- order => int($_->{order}),
+ my %data = %$_;
+
+ # Mobile app still looks in datatype_description
+ if (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) {
+ foreach my $opt (@{$_->{values}}) {
+ if ($opt->{disable}) {
+ my $message = $opt->{disable_message} || $_->{datatype_description};
+ $data{datatype_description} = $message;
+ }
+ }
}
+
+ # Remove unneeded
+ delete $data{$_} for qw(datatype protected variable order disable_form);
+ delete $data{datatype_description} unless $data{datatype_description};
+
+ $data{required} = ($_->{required} || '') eq "true" ? $true : $false;
+ \%data;
} @{ $c->stash->{category_extras}->{$c->stash->{category}} };
return \@fields;
diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm
index cbedf7a01..41c42b8a1 100644
--- a/perllib/FixMyStreet/App/Controller/Report/Update.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm
@@ -4,6 +4,7 @@ use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use utf8;
use Path::Class;
use List::Util 'first';
use Utils;
@@ -105,6 +106,17 @@ sub process_user : Private {
# Update form includes two username fields: #form_username_register and #form_username_sign_in
$params{username} = (first { $_ } $c->get_param_list('username')) || '';
+ my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ my $anon_details = $c->cobrand->anonymous_account;
+ my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} });
+ $user->name($anon_details->{name});
+ $update->user($user);
+ $update->name($user->name);
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ return 1;
+ }
+
# Extra block to use 'last'
if ( $c->user_exists ) { {
my $user = $c->user->obj;
@@ -115,13 +127,16 @@ sub process_user : Private {
}
$user->name( Utils::trim_text( $params{name} ) ) if $params{name};
+ $update->name($user->name);
my $title = Utils::trim_text( $params{fms_extra_title} );
$user->title( $title ) if $title;
$update->user( $user );
# Just in case, make sure the user will have a name
if ($c->stash->{contributing_as_body} or $c->stash->{contributing_as_anonymous_user}) {
- $user->name($user->from_body->name) unless $user->name;
+ my $name = $user->moderating_user_name;
+ $update->name($name);
+ $user->name($name) unless $user->name;
}
return 1;
@@ -143,7 +158,7 @@ sub process_user : Private {
oauth_update => { $update->get_inflated_columns }
};
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
- $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
+ $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.');
return 1;
}
my $user = $c->user->obj;
@@ -155,6 +170,7 @@ sub process_user : Private {
$update->user->name( Utils::trim_text( $params{name} ) )
if $params{name};
+ $update->name($update->user->name);
$update->user->title( Utils::trim_text( $params{fms_extra_title} ) )
if $params{fms_extra_title};
@@ -244,8 +260,7 @@ This makes sure we only proceed to processing if we've had the form submitted
sub check_form_submitted : Private {
my ( $self, $c ) = @_;
- return if $c->stash->{problem}->get_extra_metadata('closed_updates');
- return if $c->cobrand->call_hook(updates_disallowed => $c->stash->{problem});
+ return if $c->cobrand->updates_disallowed($c->stash->{problem});
return $c->get_param('submit_update') || '';
}
@@ -277,14 +292,21 @@ sub process_update : Private {
$c->stash->{contributing_as_body} = $c->user_exists && $c->user->contributing_as('body', $c, $update->problem->bodies_str_ids);
$c->stash->{contributing_as_anonymous_user} = $c->user_exists && $c->user->contributing_as('anonymous_user', $c, $update->problem->bodies_str_ids);
+
+ # This is also done in process_user, but is needed here for anonymous() just below
+ my $anon_button = $c->cobrand->allow_anonymous_reports($update->problem->category) eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ $c->stash->{contributing_as_body} = undef;
+ $c->stash->{contributing_as_another_user} = undef;
+ }
+
+
if ($c->stash->{contributing_as_body}) {
- $update->name($c->user->from_body->name);
$update->anonymous(0);
} elsif ($c->stash->{contributing_as_anonymous_user}) {
- $update->name($c->user->from_body->name);
$update->anonymous(1);
} else {
- $update->name($name);
$update->anonymous($c->get_param('may_show_name') ? 0 : 1);
}
@@ -366,7 +388,7 @@ sub check_for_errors : Private {
);
# if using social login then we don't care about name and email errors
- $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in');
+ $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0;
if ( $c->stash->{is_social_user} ) {
delete $field_errors{name};
delete $field_errors{username};
@@ -394,6 +416,13 @@ sub check_for_errors : Private {
#push @{ $c->stash->{errors} },
# _('There were problems with your update. Please see below.');
+ if ( $c->cobrand->allow_anonymous_reports ) {
+ my $anon_details = $c->cobrand->anonymous_account;
+ my $update = $c->stash->{update};
+ $update->user->email(undef) if $update->user->email eq $anon_details->{email};
+ $update->name(undef) if $update->name && $update->name eq $anon_details->{name};
+ }
+
return;
}
@@ -404,10 +433,8 @@ sub tokenize_user : Private {
name => $update->user->name,
password => $update->user->password,
};
- $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+ $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ])
+ if $c->get_param('oauth_need_email');
}
=head2 save_update
@@ -440,11 +467,7 @@ sub save_update : Private {
$c->stash->{detach_to} = '/report/update/oauth_callback';
$c->stash->{detach_args} = [$token->token];
- if ( $c->get_param('facebook_sign_in') ) {
- $c->detach('/auth/social/facebook_sign_in');
- } elsif ( $c->get_param('twitter_sign_in') ) {
- $c->detach('/auth/social/twitter_sign_in');
- }
+ $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in');
}
if ( $c->cobrand->never_confirm_updates ) {
@@ -508,7 +531,7 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $update->user->has_2fa;
my $data = $c->stash->{token_data};
@@ -585,8 +608,20 @@ sub process_confirmation : Private {
for (qw(name facebook_id twitter_id)) {
$comment->user->$_( $data->{$_} ) if $data->{$_};
}
+ $comment->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id};
+ $comment->user->extra({
+ %{ $comment->user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
$comment->user->password( $data->{password}, 1 ) if $data->{password};
$comment->user->update;
+ # Make sure extra oauth state is restored, if applicable
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
}
if ($comment->user->email_verified) {
@@ -636,6 +671,8 @@ sub signup_for_alerts : Private {
$alert->disable();
}
+ $c->cobrand->call_hook(update_email_shortlisted_user => $update);
+
return 1;
}
diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm
index 49bdce379..97976ebe3 100644
--- a/perllib/FixMyStreet/App/Controller/Reports.pm
+++ b/perllib/FixMyStreet/App/Controller/Reports.pm
@@ -151,6 +151,7 @@ sub ward : Path : Args(2) {
if @wards;
$c->forward( 'check_canonical_url', [ $body ] );
$c->forward( 'stash_report_filter_status' );
+ $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]);
$c->forward( 'load_and_group_problems' );
if ($c->get_param('ajax')) {
@@ -164,20 +165,44 @@ sub ward : Path : Args(2) {
$c->stash->{stats} = $c->cobrand->get_report_stats();
+ $c->forward('setup_categories_and_map');
+
+ # List of wards
+ if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) {
+ my $children = $c->stash->{body}->first_area_children;
+ unless ($children->{error}) {
+ foreach (values %$children) {
+ $_->{url} = $c->uri_for( $c->stash->{body_url}
+ . '/' . $c->cobrand->short_name( $_ )
+ );
+ }
+ $c->stash->{children} = $children;
+ }
+ }
+}
+
+sub setup_categories_and_map :Private {
+ my ($self, $c) = @_;
+
my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, {
- columns => [ 'id', 'category', 'extra' ],
+ columns => [ 'id', 'category', 'extra', 'body_id', 'send_method' ],
distinct => 1,
- order_by => [ 'category' ],
- } )->all;
+ } )->all_sorted;
+
+ $c->cobrand->call_hook('munge_reports_category_list', \@categories);
+
$c->stash->{filter_categories} = \@categories;
$c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) };
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $pins = $c->stash->{pins} || [];
+ my $areas = [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ];
+ $c->cobrand->call_hook(munge_reports_area_list => $areas);
my %map_params = (
latitude => @$pins ? $pins->[0]{latitude} : 0,
longitude => @$pins ? $pins->[0]{longitude} : 0,
- area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ],
+ area => $areas,
any_zoom => 1,
);
FixMyStreet::Map::display_map(
@@ -185,19 +210,6 @@ sub ward : Path : Args(2) {
);
$c->cobrand->tweak_all_reports_map( $c );
-
- # List of wards
- if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) {
- my $children = $c->stash->{body}->first_area_children;
- unless ($children->{error}) {
- foreach (values %$children) {
- $_->{url} = $c->uri_for( $c->stash->{body_url}
- . '/' . $c->cobrand->short_name( $_ )
- );
- }
- $c->stash->{children} = $children;
- }
- }
}
sub rss_area : Path('/rss/area') : Args(1) {
@@ -287,12 +299,12 @@ sub rss_ward : Path('/rss/reports') : Args(2) {
if ($c->stash->{ward}) {
# Problems sent to a council, restricted to a ward
$c->stash->{type} = 'ward_problems';
- $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name, WARD => $c->stash->{ward}{name} };
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name, WARD => $c->stash->{ward}{name} };
$c->stash->{db_params} = [ $c->stash->{body}->id, $c->stash->{ward}->{id} ];
} else {
# Problems sent to a council
$c->stash->{type} = 'council_problems';
- $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name };
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name };
$c->stash->{db_params} = [ $c->stash->{body}->id ];
}
@@ -391,9 +403,7 @@ sub ward_check : Private {
$parent_id = $c->stash->{area}->{id};
}
- my $qw = FixMyStreet::MapIt::call('area/children', [ $parent_id ],
- type => $c->cobrand->area_types_children,
- );
+ my $qw = $c->cobrand->fetch_area_children($parent_id);
my %names = map { $c->cobrand->short_name({ name => $_ }) => 1 } @wards;
my @areas;
foreach my $area (sort { $a->{name} cmp $b->{name} } values %$qw) {
@@ -548,9 +558,51 @@ sub load_dashboard_data : Private {
sub load_and_group_problems : Private {
my ( $self, $c ) = @_;
- $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]);
+ my $parameters = $c->forward('load_problems_parameters');
+ my $body = $c->stash->{body}; # Might be undef
my $page = $c->get_param('p') || 1;
+
+ my $problems = $c->cobrand->problems;
+ my $where = $parameters->{where};
+ my $filter = $parameters->{filter};
+
+ if ($where->{areas} || $body) {
+ $problems = $problems->to_body($body);
+ }
+
+ $problems = $problems->search(
+ $where,
+ $filter
+ )->include_comment_counts->page( $page );
+
+ $c->stash->{pager} = $problems->pager;
+
+ my ( %problems, @pins );
+ while ( my $problem = $problems->next ) {
+ if ( !$body ) {
+ add_row( $c, $problem, 0, \%problems, \@pins );
+ next;
+ }
+ # Add to bodies it was sent to
+ my $bodies = $problem->bodies_str_ids;
+ foreach ( @$bodies ) {
+ next if $_ != $body->id;
+ add_row( $c, $problem, $_, \%problems, \@pins );
+ }
+ }
+
+ $c->stash(
+ problems => \%problems,
+ pins => \@pins,
+ );
+
+ return 1;
+}
+
+sub load_problems_parameters : Private {
+ my ($self, $c) = @_;
+
my $category = [ $c->get_param_list('filter_category', 1) ];
my $states = $c->stash->{filter_problem_states};
@@ -563,7 +615,7 @@ sub load_and_group_problems : Private {
my $body = $c->stash->{body}; # Might be undef
my $filter = {
- order_by => $c->stash->{sort_order},
+ order_by => [ $c->stash->{sort_order}, { -desc => 'me.id' } ],
rows => $c->cobrand->reports_per_page,
};
if ($c->user_exists && $body) {
@@ -597,15 +649,10 @@ sub load_and_group_problems : Private {
$where->{category} = $category;
}
- my $problems = $c->cobrand->problems;
-
if ($c->stash->{wards}) {
$where->{areas} = [
map { { 'like', '%,' . $_->{id} . ',%' } } @{$c->stash->{wards}}
];
- $problems = $problems->to_body($body);
- } elsif ($body) {
- $problems = $problems->to_body($body);
}
if (my $bbox = $c->get_param('bbox')) {
@@ -614,52 +661,21 @@ sub load_and_group_problems : Private {
$where->{longitude} = { '>=', $min_lon, '<', $max_lon };
}
- my $cobrand_problems = $c->cobrand->call_hook('munge_load_and_group_problems', $where, $filter);
-
- # JS will request the same (or more) data client side
- return if $c->get_param('js');
+ $c->cobrand->call_hook('munge_load_and_group_problems', $where, $filter);
- if ($cobrand_problems) {
- $problems = $cobrand_problems;
- } else {
- $problems = $problems->search(
- $where,
- $filter
- )->include_comment_counts->page( $page );
-
- $c->stash->{pager} = $problems->pager;
- }
-
- my ( %problems, @pins );
- while ( my $problem = $problems->next ) {
- if ( !$body ) {
- add_row( $c, $problem, 0, \%problems, \@pins );
- next;
- }
- # Add to bodies it was sent to
- my $bodies = $problem->bodies_str_ids;
- foreach ( @$bodies ) {
- next if $_ != $body->id;
- add_row( $c, $problem, $_, \%problems, \@pins );
- }
- }
-
- $c->stash(
- problems => \%problems,
- pins => \@pins,
- );
-
- return 1;
+ return {
+ where => $where,
+ filter => $filter,
+ };
}
-
sub check_non_public_reports_permission : Private {
my ($self, $c, $where) = @_;
if ( $c->user_exists ) {
my $user_has_permission;
- if ( $c->user->is_super_user ) {
+ if ( $c->user->is_superuser ) {
$user_has_permission = 1;
} else {
my $body = $c->stash->{body};
@@ -702,8 +718,9 @@ sub stash_report_filter_status : Private {
my @status = $c->get_param_list('status', 1);
@status = ($c->stash->{page} eq 'my' ? 'all' : $c->cobrand->on_map_default_status) unless @status;
- my %status = map { $_ => 1 } @status;
+ $c->cobrand->call_hook(hook_report_filter_status => \@status);
+ my %status = map { $_ => 1 } @status;
my %filter_problem_states;
my %filter_status;
@@ -810,7 +827,12 @@ sub ajax : Private {
my @pins = map {
my $p = $_;
# lat, lon, 'colour', ID, title, type/size, draggable
- [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ]
+ my $parts = [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ];
+ # Some reports may only be visible on a specific cobrand on this FMS site.
+ # If that's the case, include the base URL for the pin's cobrand here so
+ # the app can link to the right place.
+ push @$parts, $p->{base_url} if $p->{base_url};
+ $parts;
} @{$c->stash->{pins}};
my $list_html = $c->render_fragment($template);
diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm
index 340c930c2..71dcf8e27 100644
--- a/perllib/FixMyStreet/App/Controller/Root.pm
+++ b/perllib/FixMyStreet/App/Controller/Root.pm
@@ -39,8 +39,11 @@ sub auto : Private {
# decide which cobrand this request should use
$c->setup_request();
+ $c->forward('check_password_expiry');
$c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed');
+ $c->forward('/offline/_stash_manifest_theme', [ $c->cobrand->moniker ]);
+
return 1;
}
@@ -122,7 +125,9 @@ 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 ]);
+ $c->stash->{title} = _('Access denied');
+ $error_msg ||= _("Sorry, you don't have permission to do that.");
+ $c->detach('page_error', [ $error_msg, 403 ]);
}
sub page_error_400_bad_request : Private {
@@ -156,14 +161,30 @@ sub check_login_required : Private {
}x;
return if $c->request->path =~ $whitelist;
- # Blacklisted URLs immediately 404
- # This is primarily to work around a Safari bug where the appcache
- # URL is requested in an infinite loop if it returns a 302 redirect.
- $c->detach('/page_error_404_not_found', []) if $c->request->path =~ /^offline/;
-
$c->detach( '/auth/redirect' );
}
+sub check_password_expiry : Private {
+ my ($self, $c) = @_;
+
+ return unless $c->user_exists;
+
+ return if $c->action eq $c->controller('JS')->action_for('translation_strings');
+ return if $c->controller eq $c->controller('Auth');
+
+ my $expiry = $c->cobrand->call_hook('password_expiry');
+ return unless $expiry;
+
+ my $last_change = $c->user->get_extra_metadata('last_password_change') || 0;
+ my $midnight = int(time()/86400)*86400;
+ my $expired = $last_change + $expiry < $midnight;
+ return unless $expired;
+
+ my $uri = $c->uri_for('/auth/expired');
+ $c->res->redirect( $uri );
+ $c->detach;
+}
+
=head2 end
Attempt to render a view, if needed.
diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm
index 443e45b93..55b3088e7 100755
--- a/perllib/FixMyStreet/App/Controller/Rss.pm
+++ b/perllib/FixMyStreet/App/Controller/Rss.pm
@@ -186,6 +186,7 @@ sub generate : Private {
$c->stash->{rss} = new XML::RSS(
version => '2.0',
encoding => 'UTF-8',
+ stylesheet => '/rss/xsl',
encode_output => undef
);
$c->stash->{rss}->add_module(
@@ -222,8 +223,11 @@ sub query_main : Private {
# FIXME Do this in a nicer way at some point in the future...
my $query = 'select * from ' . $alert_type->item_table . ' where '
. ($alert_type->head_table ? $alert_type->head_table . '_id=? and ' : '')
- . $alert_type->item_where . ' order by '
- . $alert_type->item_order;
+ . $alert_type->item_where . ' ';
+ if ($c->cobrand->can('problems_sql_restriction')) {
+ $query .= $c->cobrand->problems_sql_restriction($alert_type->item_table);
+ }
+ $query .= ' order by ' . $alert_type->item_order;
my $rss_limit = FixMyStreet->config('RSS_LIMIT');
$query .= " limit $rss_limit" unless $c->stash->{type} =~ /^all/;
@@ -298,9 +302,8 @@ sub add_row : Private {
$item{description} .= encode_entities("\n<br>$address") if $address;
}
- my $recipient_name = $c->cobrand->contact_name;
$item{description} .= encode_entities("\n<br><a href='$url'>" .
- sprintf(_("Report on %s"), $recipient_name) . "</a>");
+ sprintf(_("Report on %s"), $c->stash->{site_name}) . "</a>");
if ($row->{latitude} || $row->{longitude}) {
$item{georss} = { point => "$row->{latitude} $row->{longitude}" };
@@ -328,6 +331,7 @@ sub add_parameters : Private {
foreach ( keys %{ $c->stash->{title_params} } ) {
$row->{$_} = $c->stash->{title_params}->{$_};
}
+ $row->{SITE_NAME} = $c->stash->{site_name};
(my $title = _($alert_type->head_title)) =~ s/\{\{(.*?)}}/$row->{$1}/g;
(my $link = $alert_type->head_link) =~ s/\{\{(.*?)}}/$row->{$1}/g;
@@ -377,6 +381,20 @@ sub redirect_lat_lon : Private {
$c->res->redirect( "/rss/l/$lat,$lon" . $d_str . $state_qs );
}
+sub xsl : Path {
+ my ($self, $c) = @_;
+
+ my @include_path = @{ $c->cobrand->path_to_email_templates($c->stash->{lang_code}) };
+ my $vars = {
+ %{ $c->stash },
+ additional_template_paths => \@include_path,
+ };
+ my $body = $c->view('Email')->render($c, 'xsl.xsl', $vars);
+
+ $c->response->header('Content-Type' => 'text/xml; charset=utf-8');
+ $c->response->body($body);
+}
+
=head1 AUTHOR
Matthew Somerville
diff --git a/perllib/FixMyStreet/App/Controller/Status.pm b/perllib/FixMyStreet/App/Controller/Status.pm
index 57c8f362e..e56a7930a 100755
--- a/perllib/FixMyStreet/App/Controller/Status.pm
+++ b/perllib/FixMyStreet/App/Controller/Status.pm
@@ -31,7 +31,7 @@ sub index : Path : Args(0) {
# superusers. It doesn't have anything sensitive
$c->stash->{admin_type} = 'super';
# Fetch summary stats from admin front page
- $c->forward('/admin/index');
+ $c->forward('/admin/stats/gather');
# Fetch git version
$c->forward('/admin/config_page');
diff --git a/perllib/FixMyStreet/App/Controller/Test.pm b/perllib/FixMyStreet/App/Controller/Test.pm
new file mode 100644
index 000000000..5ec4bebf3
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Test.pm
@@ -0,0 +1,60 @@
+package FixMyStreet::App::Controller::Test;
+use Moose;
+use namespace::autoclean;
+
+use File::Basename;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Test - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Test-helping Catalyst Controller.
+
+=head1 METHODS
+
+=over 4
+
+=item auto
+
+Makes sure this controller is only available when run in test.
+
+=cut
+
+sub auto : Private {
+ my ($self, $c) = @_;
+ $c->detach( '/page_error_404_not_found' ) unless FixMyStreet->test_mode;
+ return 1;
+}
+
+=item setup
+
+Sets up a particular browser test.
+
+=cut
+
+sub setup : Path('/_test/setup') : Args(1) {
+ my ( $self, $c, $test ) = @_;
+ if ($test eq 'regression-duplicate-hide') {
+ my $problem = FixMyStreet::DB->resultset("Problem")->find(1);
+ $problem->update({ category => 'Skips' });
+ $c->response->body("OK");
+ }
+}
+
+sub teardown : Path('/_test/teardown') : Args(1) {
+ my ( $self, $c, $test ) = @_;
+ if ($test eq 'regression-duplicate-hide') {
+ my $problem = FixMyStreet::DB->resultset("Problem")->find(1);
+ $problem->update({ category => 'Potholes' });
+ $c->response->body("OK");
+ }
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm
index 659d763de..c4e601a85 100644
--- a/perllib/FixMyStreet/App/Controller/Tokens.pm
+++ b/perllib/FixMyStreet/App/Controller/Tokens.pm
@@ -185,9 +185,7 @@ sub alert_to_reporter : Path('/R') {
my $problem = $c->model('DB::Problem')->find( { id => $problem_id } )
|| $c->detach('token_error');
- $c->detach('token_too_old') if $auth_token->created < DateTime->now->subtract( months => 1 );
-
- $c->flash->{alert_to_reporter} = 1;
+ $c->flash->{alert_to_reporter} = $problem->id;
my $report_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url;
$c->res->redirect($report_uri);
}