package FixMyStreet::App::Controller::Report;
use utf8;
use Moose;
use namespace::autoclean;
use JSON::MaybeXS;
use List::MoreUtils qw(any);
use Utils;
BEGIN { extends 'Catalyst::Controller'; }
=head1 NAME
FixMyStreet::App::Controller::Report - display a report
=head1 DESCRIPTION
Show a report
=head1 ACTIONS
=head2 index
Redirect to homepage unless we have a homepage template,
in which case show that.
=cut
sub index : Path('') : Args(0) {
my ( $self, $c ) = @_;
if ($c->stash->{homepage_template}) {
$c->stash->{template} = 'index.html';
} else {
$c->res->redirect('/');
}
}
=head2 id
Load in ID, for use by chained pages.
=cut
sub id :PathPart('report') :Chained :CaptureArgs(1) {
my ( $self, $c, $id ) = @_;
if (
$id =~ m{ ^ 3D (\d+) $ }x # Some council with bad email software
|| $id =~ m{ ^(\d+) \D .* $ }x # trailing garbage
)
{
$c->res->redirect( $c->uri_for($1), 301 );
$c->detach;
}
$c->forward( 'load_problem_or_display_error', [ $id ] );
}
=head2 ajax
Return JSON formatted details of a report.
URL used by mobile app so remains /report/ajax/N.
=cut
sub ajax : Path('ajax') : Args(1) {
my ( $self, $c, $id ) = @_;
$c->stash->{ajax} = 1;
$c->forward('load_problem_or_display_error', [ $id ]);
$c->forward('display');
}
=head2 display
Display a report.
=cut
sub display :PathPart('') :Chained('id') :Args(0) {
my ( $self, $c ) = @_;
$c->forward('/auth/get_csrf_token');
$c->forward( 'load_updates' );
$c->forward( 'format_problem_for_display' );
my $permissions = $c->stash->{permissions} ||= $c->forward('fetch_permissions');
my $staff_user = $c->user_exists && ($c->user->is_superuser || $c->user->belongs_to_body($c->stash->{problem}->bodies_str));
if ($staff_user) {
# Check assigned categories feature
my $okay = 1;
my $contact = $c->stash->{problem}->contact;
if ($contact && ($c->user->get_extra_metadata('assigned_categories_only') || $contact->get_extra_metadata('assigned_users_only'))) {
my $user_cats = $c->user->get_extra_metadata('categories') || [];
$okay = any { $contact->id eq $_ } @$user_cats;
}
if ($okay) {
$c->stash->{relevant_staff_user} = 1;
} else {
# Remove all staff permissions
$permissions = $c->stash->{permissions} = {};
}
}
if (grep { $permissions->{$_} } qw/report_inspect report_edit_category report_edit_priority report_mark_private triage/) {
$c->stash->{template} = 'report/inspect.html';
$c->forward('inspect');
}
if ($permissions->{contribute_as_another_user}) {
$c->stash->{email} = $c->user->email;
}
}
sub moderate_report :PathPart('moderate') :Chained('id') :Args(0) {
my ( $self, $c ) = @_;
if ($c->user_exists && $c->user->can_moderate($c->stash->{problem})) {
$c->stash->{show_moderation} = 'report';
$c->stash->{template} = 'report/display.html';
$c->detach('display');
}
$c->res->redirect($c->stash->{problem}->url);
}
sub moderate_update :PathPart('moderate') :Chained('id') :Args(1) {
my ( $self, $c, $update_id ) = @_;
my $comment = $c->stash->{problem}->comments->find($update_id);
if ($c->user_exists && $comment && $c->user->can_moderate($comment)) {
$c->stash->{show_moderation} = $update_id;
$c->stash->{template} = 'report/display.html';
$c->detach('display');
}
$c->res->redirect($c->stash->{problem}->url);
}
sub support :Chained('id') :Args(0) {
my ( $self, $c ) = @_;
if ( $c->cobrand->can_support_problems && $c->user && $c->user->from_body ) {
$c->stash->{problem}->update( { interest_count => \'interest_count +1' } );
}
$c->res->redirect($c->stash->{problem}->url);
}
sub load_problem_or_display_error : Private {
my ( $self, $c, $id ) = @_;
my $attrs = { prefetch => 'contact' };
# try to load a report if the id is a number
my $problem
= ( !$id || $id =~ m{\D} ) # is id non-numeric?
? undef # ...don't even search
: $c->cobrand->problems->find( { id => $id }, $attrs )
or $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] );
# check that the problem is suitable to show.
# hidden_states includes partial and unconfirmed, but they have specific handling,
# so we check for them first.
if ( $problem->state eq 'partial' ) {
$c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] );
}
elsif ( $problem->state eq 'unconfirmed' ) {
$c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] )
unless $c->cobrand->show_unconfirmed_reports ;
}
elsif ( $problem->hidden_states->{ $problem->state } ) {
$c->detach(
'/page_error_410_gone',
[ _('That report has been removed from FixMyStreet.') ] #
);
} elsif ( $problem->non_public ) {
# Creator, and inspection users can see non_public reports
$c->stash->{problem} = $problem;
my $permissions = $c->stash->{permissions} = $c->forward('fetch_permissions');
# 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(_('Sorry, you don’t have permission to do that. If you are the problem reporter, or a member of staff, please sign in to view this report.'), $url) ]
);
}
}
$c->cobrand->call_hook(munge_problem_list => $problem);
$c->stash->{problem} = $problem;
if ( $c->user_exists && $c->user->can_moderate($problem) ) {
$c->stash->{problem_original} = $problem->find_or_new_related(
moderation_original_data => {
title => $problem->title,
detail => $problem->detail,
photo => $problem->photo,
anonymous => $problem->anonymous,
}
);
}
return 1;
}
sub load_updates : Private {
my ( $self, $c ) = @_;
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(
{
problem_id => $c->stash->{problem}->id,
whenanswered => { '!=', undef },
-or => [ {
# Any steady state open/closed
old_state => [ -and =>
{ -in => [ FixMyStreet::DB::Result::Problem::closed_states, FixMyStreet::DB::Result::Problem::open_states ] },
\'= new_state',
],
}, {
# Any reopening
new_state => 'confirmed',
} ]
},
{ order_by => 'whenanswered' }
);
my $questionnaires_fixed = $c->model('DB::Questionnaire')->search(
{
problem_id => $c->stash->{problem}->id,
whenanswered => { '!=', undef },
old_state => { -not_in => [ FixMyStreet::DB::Result::Problem::fixed_states ] },
new_state => { -in => [ FixMyStreet::DB::Result::Problem::fixed_states ] },
},
{ order_by => 'whenanswered' }
);
my @combined;
my %questionnaires_with_updates;
while (my $update = $updates->next) {
$c->cobrand->call_hook(munge_update_list => $update);
push @combined, [ $update->confirmed, $update ];
if (my $qid = $update->get_extra_metadata('questionnaire_id')) {
$questionnaires_with_updates{$qid} = $update;
}
}
while (my $q = $questionnaires_still_open->next) {
if (my $update = $questionnaires_with_updates{$q->id}) {
$update->set_extra_metadata('open_from_questionnaire', 1);
next;
}
push @combined, [ $q->whenanswered, $q ];
}
while (my $q = $questionnaires_fixed->next) {
next if $questionnaires_with_updates{$q->id};
push @combined, [ $q->whenanswered, $q ];
}
# And include moderation changes...
my $problem = $c->stash->{problem};
my $public_history = $c->cobrand->call_hook(public_moderation_history => $problem);
my $user_can_moderate = $c->user_exists && $c->user->can_moderate($problem);
if ($public_history || $user_can_moderate) {
my @history = $problem->moderation_history;
my $last_history = $problem;
foreach my $history (@history) {
push @combined, [ $history->created, {
id => 'm' . $history->id,
type => 'moderation',
last => $last_history,
entry => $history,
} ];
$last_history = $history;
}
}
@combined = map { $_->[1] } sort { $a->[0] <=> $b->[0] } @combined;
$c->stash->{updates} = \@combined;
if ($c->sessionid) {
foreach (qw(alert_to_reporter anonymized)) {
$c->stash->{$_} = $c->flash->{$_} if $c->flash->{$_};
}
}
return 1;
}
sub format_problem_for_display : Private {
my ( $self, $c ) = @_;
my $problem = $c->stash->{problem};
# upload_fileid is used by the update form on this page
$c->stash->{problem_upload_fileid} = $problem->get_photoset->data;
( $c->stash->{latitude}, $c->stash->{longitude} ) =
map { Utils::truncate_coordinate($_) }
( $problem->latitude, $problem->longitude );
unless ( $c->get_param('submit_update') ) {
$c->stash->{add_alert} = 1;
}
my $first_body = (values %{$problem->bodies})[0];
$c->stash->{extra_name_info} = $first_body && $first_body->name =~ /Bromley/ ? 1 : 0;
$c->forward('generate_map_tags');
if ( $c->stash->{ajax} ) {
$c->res->content_type('application/json; charset=utf-8');
# encode_json doesn't like DateTime objects, so strip them out
my $report_hashref = $c->cobrand->problem_as_hashref( $problem );
delete $report_hashref->{created};
delete $report_hashref->{confirmed};
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->res->body( $content );
return 1;
}
return 1;
}
sub generate_map_tags : Private {
my ( $self, $c ) = @_;
my $problem = $c->stash->{problem};
$c->stash->{page} = 'report';
FixMyStreet::Map::display_map(
$c,
latitude => $problem->latitude,
longitude => $problem->longitude,
pins => $problem->used_map
? [ $problem->pin_data('report', type => 'big', draggable => 1) ]
: [],
);
return 1;
}
=head2 delete
Endpoint for the council report hiding feature enabled for
C bodies, and Bromley. The latter is migrating
to moderation, however we'd need to inform all the other
users too about this change, at which point we can delete:
- this method
- the call to it in templates/web/base/report/display_tools.html
- the users_can_hide cobrand method, in favour of user->has_permission_to
=cut
sub delete :Chained('id') :Args(0) {
my ($self, $c) = @_;
$c->forward('/auth/check_csrf_token');
my $p = $c->stash->{problem};
return $c->res->redirect($p->url) unless $c->user_exists;
my $body = $c->user->obj->from_body;
return $c->res->redirect($p->url) unless $body;
return $c->res->redirect($p->url) unless $p->bodies->{$body->id};
$p->state('hidden');
$p->lastupdate( \'current_timestamp' );
$p->update;
$c->model('DB::AdminLog')->create( {
user => $c->user->obj,
admin_user => $c->user->from_body->name,
object_type => 'problem',
action => 'state_change',
object_id => $p->id,
} );
return $c->res->redirect($p->url);
}
sub inspect : Private {
my ( $self, $c ) = @_;
my $problem = $c->stash->{problem};
my $permissions = $c->stash->{permissions};
$c->forward('/admin/reports/categories_for_point');
$c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } };
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,
problem => $problem,
);
$c->stash->{priorities_by_category} = $priorities_by_category;
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;
}
if ($c->user->has_body_permission_to('planned_reports')) {
$c->stash->{post_inspect_url} = $c->req->referer;
}
if ($c->user->has_body_permission_to('report_edit_priority') or
$c->user->has_body_permission_to('report_inspect')
) {
$c->stash->{has_default_priority} = scalar( grep { $_->is_default } $problem->response_priorities );
}
$c->stash->{max_detailed_info_length} = $c->cobrand->max_detailed_info_length;
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 %update_params = ();
if ($permissions->{report_inspect}) {
$problem->set_extra_metadata( traffic_information => $c->get_param('traffic_information') );
if ( my $info = $c->get_param('detailed_information') ) {
$problem->set_extra_metadata( detailed_information => $info );
if ($c->cobrand->max_detailed_info_length &&
length($info) > $c->cobrand->max_detailed_info_length
) {
$valid = 0;
push @{ $c->stash->{errors} },
sprintf(
_('Detailed information is limited to %d characters.'),
$c->cobrand->max_detailed_info_length
);
}
}
if ( $c->get_param('include_update') or $c->get_param('raise_defect') ) {
$update_text = Utils::cleanup_text( $c->get_param('public_update'), { allow_multiline => 1 } );
if (!$update_text) {
$valid = 0;
$c->stash->{errors} ||= [];
push @{ $c->stash->{errors} }, _('Please provide a public update for this report.');
}
}
# Handle the state changing
my $old_state = $problem->state;
$problem->state($c->get_param('state'));
if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) {
$problem->confirmed( \'current_timestamp' );
}
if ( $problem->state eq 'hidden' ) {
$problem->get_photoset->delete_cached(plus_updates => 1);
}
if ( $problem->state eq 'duplicate') {
if (my $duplicate_of = $c->get_param('duplicate_of')) {
$problem->set_duplicate_of($duplicate_of);
} elsif (not $c->get_param('include_update')) {
$valid = 0;
push @{ $c->stash->{errors} }, _('Please provide a duplicate ID or public update for this report.');
}
} else {
$problem->unset_extra_metadata('duplicate_of');
}
if ( $problem->state ne $old_state ) {
$c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'state_change' ] );
$update_params{problem_state} = $problem->state;
my $state = $problem->state;
# If an inspector has changed the state, subscribe them to
# updates
my $options = {
cobrand => $c->cobrand->moniker,
cobrand_data => $problem->cobrand_data,
lang => $problem->lang,
};
$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/reports/edit_location', [ $problem ] ) ) {
# New lat/lon isn't valid, show an error
$valid = 0;
$c->stash->{errors} ||= [];
push @{ $c->stash->{errors} }, _('Invalid location. New location must be covered by the same council.');
}
if ($permissions->{report_inspect} || $permissions->{report_edit_category}) {
$c->forward( '/admin/reports/edit_category', [ $problem, 1 ] );
if ($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
# we need to update the problem with the new values.
my $param_prefix = lc $problem->category;
$param_prefix =~ s/[^a-z]//g;
$param_prefix = "category_" . $param_prefix . "_";
my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}};
$c->forward('/report/new/set_report_extras', [ \@contacts, $param_prefix ]);
}
# Updating priority/defect type must come after category, in case
# category has changed (and so might have priorities/defect types)
if ($permissions->{report_inspect} || $permissions->{report_edit_priority}) {
if ($c->get_param('priority')) {
$problem->response_priority( $problem->response_priorities->find({ id => $c->get_param('priority') }) );
} else {
$problem->response_priority(undef);
}
}
$c->cobrand->call_hook(report_inspect_update_extra => $problem);
$c->forward('/photo/process_photo');
if ( my $photo_error = delete $c->stash->{photo_error} ) {
$valid = 0;
push @{ $c->stash->{errors} }, $photo_error;
}
if ($valid) {
$problem->lastupdate( \'current_timestamp' );
$problem->update;
$c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'edit' ] );
if ($update_text || %update_params) {
my $timestamp = \'current_timestamp';
if (my $saved_at = $c->get_param('saved_at')) {
# this comes in as a UTC epoch but the database expects everything
# to have the FMS timezone so we need to add the timezone otherwise
# dates come back out the database at time +/- timezone offset.
$timestamp = DateTime->from_epoch(
time_zone => FixMyStreet->local_time_zone,
epoch => $saved_at
);
}
$problem->add_to_comments( {
text => $update_text,
created => $timestamp,
confirmed => $timestamp,
user => $c->user->obj,
photo => $c->stash->{upload_fileid} || undef,
%update_params,
} );
}
my $redirect_uri;
$problem->discard_changes;
# If inspector, redirect back to the map view they came from
# with the right filters. If that wasn't set, go to /around at this
# report's location.
# We go here rather than the shortlist because it makes it much
# simpler to inspect many reports in the same location. The
# shortlist is always a single click away, being on the main nav.
if ($c->user->has_body_permission_to('planned_reports')) {
unless ($redirect_uri = $c->get_param("post_inspect_url")) {
my $categories = $c->user->categories_string;
my $params = {
lat => $problem->latitude,
lon => $problem->longitude,
};
$params->{filter_category} = $categories if $categories;
$params->{js} = 1 if $c->get_param('js');
$redirect_uri = $c->uri_for( "/around", $params );
}
} elsif ( $c->cobrand->is_council && !$c->cobrand->owns_problem($problem) ) {
# This problem might no longer be visible on the current cobrand,
# if its body has changed (e.g. by virtue of the category changing)
# so redirect to a cobrand where it can be seen if necessary
$redirect_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url;
} else {
$redirect_uri = $c->uri_for( $problem->url );
}
$c->log->debug( "Redirecting to: " . $redirect_uri );
$c->res->redirect( $redirect_uri );
}
}
};
sub map :Chained('id') :Args(0) {
my ($self, $c) = @_;
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});
}
sub nearby_json :PathPart('nearby.json') :Chained('id') :Args(0) {
my ($self, $c) = @_;
my $p = $c->stash->{problem};
$c->forward('_nearby_json', [ {
latitude => $p->latitude,
longitude => $p->longitude,
categories => [ $p->category ],
ids => [ $p->id ],
} ]);
}
sub _nearby_json :Private {
my ($self, $c, $params) = @_;
# This is for the list template, this is a list on that page.
$c->stash->{page} = 'report';
# distance in metres
my $dist = $c->get_param('distance') || '';
$dist = 1000 unless $dist =~ /^\d+$/;
$dist = 1000 if $dist > 1000;
$params->{distance} = $dist / 1000;
my $pin_size = $c->get_param('pin_size') || '';
$pin_size = 'small' unless $pin_size =~ /^(mini|small|normal|big)$/;
$params->{extra} = $c->cobrand->call_hook('display_location_extra_params');
$params->{limit} = 5;
my $nearby = $c->model('DB::Nearby')->nearby($c, %$params);
# Want to treat these as if they were on map
$nearby = [ map { $_->problem } @$nearby ];
my @pins = map {
my $p = $_->pin_data('around');
[ $p->{latitude}, $p->{longitude}, $p->{colour},
$p->{id}, $p->{title}, $pin_size, JSON->false
]
} @$nearby;
my $list_html = $c->render_fragment(
'report/nearby.html',
{ reports => $nearby, inline_maps => $c->get_param("inline_maps") ? 1 : 0 }
);
my $json = { pins => \@pins };
$json->{reports_list} = $list_html if $list_html;
my $body = encode_json($json);
$c->res->content_type('application/json; charset=utf-8');
$c->res->body($body);
}
=head2 fetch_permissions
Returns a hash of the user's permissions, applied to the problem
in $c->stash->{problem}.
=cut
sub fetch_permissions : Private {
my ( $self, $c ) = @_;
return {} unless $c->user_exists;
return $c->user->permissions($c->stash->{problem});
};
sub stash_category_groups : Private {
my ( $self, $c, $contacts, $combine_multiple ) = @_;
my %category_groups = ();
for my $category (@$contacts) {
my $group = $category->{group} // $category->groups;
my @groups = @$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;
}
sub assigned_users_only : Private {
my ($self, $c, $categories) = @_;
# Assigned only category checking
if ($c->user_exists && $c->user->from_body) {
my @assigned_users_only = grep { $_->get_extra_metadata('assigned_users_only') } @$categories;
$c->stash->{assigned_users_only} = { map { $_->category => 1 } @assigned_users_only };
$c->stash->{assigned_categories_only} = $c->user->get_extra_metadata('assigned_categories_only');
$c->stash->{user_categories} = { map { $_ => 1 } @{$c->user->categories} };
}
}
__PACKAGE__->meta->make_immutable;
1;