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.pm814
-rw-r--r--perllib/FixMyStreet/App/Controller/Alert.pm529
-rw-r--r--perllib/FixMyStreet/App/Controller/Around.pm285
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm283
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact.pm237
-rw-r--r--perllib/FixMyStreet/App/Controller/Council.pm107
-rw-r--r--perllib/FixMyStreet/App/Controller/JSON.pm141
-rw-r--r--perllib/FixMyStreet/App/Controller/Location.pm130
-rw-r--r--perllib/FixMyStreet/App/Controller/My.pm69
-rw-r--r--perllib/FixMyStreet/App/Controller/Photo.pm103
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Questionnaire.pm325
-rw-r--r--perllib/FixMyStreet/App/Controller/Report.pm144
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/New.pm1047
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/Update.pm343
-rw-r--r--perllib/FixMyStreet/App/Controller/Reports.pm439
-rw-r--r--perllib/FixMyStreet/App/Controller/Root.pm108
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Rss.pm342
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Static.pm56
-rw-r--r--perllib/FixMyStreet/App/Controller/Tokens.pm240
19 files changed, 5742 insertions, 0 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm
new file mode 100644
index 000000000..fbd50a973
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin.pm
@@ -0,0 +1,814 @@
+package FixMyStreet::App::Controller::Admin;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use POSIX qw(strftime strcoll);
+use Digest::MD5 qw(md5_hex);
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Admin- Catalyst Controller
+
+=head1 DESCRIPTION
+
+Admin pages
+
+=head1 METHODS
+
+=cut
+
+=head2 summary
+
+Redirect to index page. There to make the allowed pages stuff neater
+
+=cut
+
+sub begin : Private {
+ my ( $self, $c ) = @_;
+
+ $c->uri_disposition('relative');
+}
+
+sub summary : Path( 'summary' ) : Args(0) {
+ my ( $self, $c ) = @_;
+ $c->go( 'index' );
+}
+
+=head2 index
+
+Displays some summary information for the requests.
+
+=cut
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ my ( $sql_restriction, $id, $site_restriction ) = $c->cobrand->site_restriction();
+
+ my $problems = $c->cobrand->problems->summary_count;
+
+ my %prob_counts =
+ map { $_->state => $_->get_column('state_count') } $problems->all;
+
+ %prob_counts =
+ map { $_ => $prob_counts{$_} || 0 }
+ qw(confirmed fixed unconfirmed hidden partial);
+ $c->stash->{problems} = \%prob_counts;
+ $c->stash->{total_problems_live} =
+ $prob_counts{confirmed} + $prob_counts{fixed};
+ $c->stash->{total_problems_users} = $c->cobrand->problems->unique_users;
+
+ my $comments = $c->model('DB::Comment')->summary_count( $site_restriction );
+
+ my %comment_counts =
+ map { $_->state => $_->get_column('state_count') } $comments->all;
+
+ $c->stash->{comments} = \%comment_counts;
+
+ my $alerts = $c->model('DB::Alert')->summary_count( $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( $c->cobrand->contact_restriction );
+
+ my %contact_counts =
+ map { $_->confirmed => $_->get_column('confirmed_count') } $contacts->all;
+
+ $contact_counts{0} ||= 0;
+ $contact_counts{1} ||= 0;
+ $contact_counts{total} = $contact_counts{0} + $contact_counts{1};
+
+ $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->stash->{categories} = $c->cobrand->problems->categories_summary();
+
+ return 1;
+}
+
+sub timeline : Path( 'timeline' ) : Args(0) {
+ my ($self, $c) = @_;
+
+ $c->forward('check_page_allowed');
+
+ my ( $sql_restriction, $id, $site_restriction ) = $c->cobrand->site_restriction();
+ my %time;
+
+ $c->model('DB')->schema->storage->sql_maker->quote_char( '"' );
+
+ my $probs = $c->cobrand->problems->timeline;
+
+ foreach ($probs->all) {
+ push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created_local, obj => $_ };
+ push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed_local, obj => $_ } if $_->confirmed;
+ push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent_local, obj => $_ } if $_->whensent;
+ }
+
+ my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction );
+
+ foreach ($questionnaires->all) {
+ push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent_local, obj => $_ };
+ push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered_local, obj => $_ } if $_->whenanswered;
+ }
+
+ my $updates = $c->model('DB::Comment')->timeline( $site_restriction );
+
+ foreach ($updates->all) {
+ push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created_local, obj => $_} ;
+ }
+
+ my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction );
+
+ foreach ($alerts->all) {
+ push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed_local, obj => $_ };
+ }
+
+ $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction );
+
+ foreach ($alerts->all) {
+ push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled_local, obj => $_ };
+ }
+
+ $c->model('DB')->schema->storage->sql_maker->quote_char( '' );
+
+ $c->stash->{time} = \%time;
+
+ return 1;
+}
+
+sub questionnaire : Path('questionnaire') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ my $questionnaires = $c->model('DB::Questionnaire')->search(
+ { whenanswered => \'is not null' }, { group_by => [ 'ever_reported' ], select => [ 'ever_reported', { count => 'me.id' } ], as => [qw/reported questionnaire_count/] }
+ );
+
+
+ my %questionnaire_counts = map { $_->get_column( 'reported' ) => $_->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} = \%questionnaire_counts;
+
+ $c->stash->{state_changes_count} = $c->model('DB::Questionnaire')->search(
+ { whenanswered => \'is not null' }
+ )->count;
+ $c->stash->{state_changes} = $c->model('DB::Questionnaire')->search(
+ { whenanswered => \'is not null' },
+ {
+ group_by => [ 'old_state', 'new_state' ],
+ columns => [ 'old_state', 'new_state', { c => { count => 'id' } } ],
+ },
+ );
+
+ return 1;
+}
+
+sub council_list : Path('council_list') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ my $edit_activity = $c->model('DB::ContactsHistory')->search(
+ undef,
+ {
+ select => [ 'editor', { count => 'contacts_history_id', -as => 'c' } ],
+ group_by => ['editor'],
+ order_by => { -desc => 'c' }
+ }
+ );
+
+ $c->stash->{edit_activity} = $edit_activity;
+
+ my @area_types = $c->cobrand->area_types;
+ my $areas = mySociety::MaPit::call('areas', \@area_types);
+
+ my @councils_ids = sort { strcoll($areas->{$a}->{name}, $areas->{$b}->{name}) } keys %$areas;
+ @councils_ids = $c->cobrand->filter_all_council_ids_list( @councils_ids );
+
+ my $contacts = $c->model('DB::Contact')->search(
+ undef,
+ {
+ select => [ 'area_id', { count => 'id' }, { count => \'case when deleted then 1 else null end' },
+ { count => \'case when confirmed then 1 else null end' } ],
+ as => [qw/area_id c deleted confirmed/],
+ group_by => [ 'area_id' ],
+ result_class => 'DBIx::Class::ResultClass::HashRefInflator'
+ }
+ );
+
+ my %council_info = map { $_->{area_id} => $_ } $contacts->all;
+
+ my @no_info = grep { !$council_info{$_} } @councils_ids;
+ my @one_plus_deleted = grep { $council_info{$_} && $council_info{$_}->{deleted} } @councils_ids;
+ my @unconfirmeds = grep { $council_info{$_} && !$council_info{$_}->{deleted} && $council_info{$_}->{confirmed} != $council_info{$_}->{c} } @councils_ids;
+ my @all_confirmed = grep { $council_info{$_} && !$council_info{$_}->{deleted} && $council_info{$_}->{confirmed} == $council_info{$_}->{c} } @councils_ids;
+
+ $c->stash->{areas} = $areas;
+ $c->stash->{counts} = \%council_info;
+ $c->stash->{no_info} = \@no_info;
+ $c->stash->{one_plus_deleted} = \@one_plus_deleted;
+ $c->stash->{unconfirmeds} = \@unconfirmeds;
+ $c->stash->{all_confirmed} = \@all_confirmed;
+
+ return 1;
+}
+
+sub council_contacts : Path('council_contacts') : Args(1) {
+ my ( $self, $c, $area_id ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ my $posted = $c->req->param('posted') || '';
+ $c->stash->{area_id} = $area_id;
+
+ $c->forward( 'get_token' );
+
+ if ( $posted ) {
+ $c->log->debug( 'posted' );
+ $c->forward('update_contacts');
+ }
+
+ $c->forward('display_contacts');
+
+ return 1;
+}
+
+sub update_contacts : Private {
+ my ( $self, $c ) = @_;
+
+ my $posted = $c->req->param('posted');
+ my $editor = $c->req->remote_user || _('*unknown*');
+
+ if ( $posted eq 'new' ) {
+ $c->forward('check_token');
+
+ my $category = $self->trim( $c->req->param( 'category' ) );
+ my $email = $self->trim( $c->req->param( 'email' ) );
+
+ $category = 'Empty property' if $c->cobrand->moniker eq 'emptyhomes';
+
+ my $contact = $c->model('DB::Contact')->find_or_new(
+ {
+ area_id => $c->stash->{area_id},
+ category => $category,
+ }
+ );
+
+ $contact->email( $email );
+ $contact->confirmed( $c->req->param('confirmed') ? 1 : 0 );
+ $contact->deleted( $c->req->param('deleted') ? 1 : 0 );
+ $contact->note( $c->req->param('note') );
+ $contact->whenedited( \'ms_current_timestamp()' );
+ $contact->editor( $editor );
+
+ if ( $contact->in_storage ) {
+ $c->stash->{updated} = _('Values updated');
+
+ # NB: History is automatically stored by a trigger in the database
+ $contact->update;
+ } else {
+ $c->stash->{updated} = _('New category contact added');
+ $contact->insert;
+ }
+
+ } elsif ( $posted eq 'update' ) {
+ $c->forward('check_token');
+
+ my @categories = $c->req->param('confirmed');
+
+ my $contacts = $c->model('DB::Contact')->search(
+ {
+ area_id => $c->stash->{area_id},
+ category => { -in => \@categories },
+ }
+ );
+
+ $contacts->update(
+ {
+ confirmed => 1,
+ whenedited => \'ms_current_timestamp()',
+ note => 'Confirmed',
+ editor => $editor,
+ }
+ );
+
+ $c->stash->{updated} = _('Values updated');
+ }
+}
+
+sub display_contacts : Private {
+ my ( $self, $c ) = @_;
+
+ $c->forward('setup_council_details');
+
+ my $area_id = $c->stash->{area_id};
+
+ my $contacts = $c->model('DB::Contact')->search(
+ { area_id => $area_id },
+ { order_by => ['category'] }
+ );
+
+ $c->stash->{contacts} = $contacts;
+
+ if ( $c->req->param('text') && $c->req->param('text') == 1 ) {
+ $c->stash->{template} = 'admin/council_contacts.txt';
+ $c->res->content_type('text/plain; charset=utf-8');
+ return 1;
+ }
+
+ return 1;
+}
+
+sub setup_council_details : Private {
+ my ( $self, $c ) = @_;
+
+ my $area_id = $c->stash->{area_id};
+
+ my $mapit_data = mySociety::MaPit::call('area', $area_id);
+
+ $c->stash->{council_name} = $mapit_data->{name};
+
+ my $example_postcode = mySociety::MaPit::call('area/example_postcode', $area_id);
+
+ if ($example_postcode && ! ref $example_postcode) {
+ $c->stash->{example_pc} = $example_postcode;
+ }
+
+ return 1;
+}
+
+sub council_edit_all : Path('council_edit') {
+ my ( $self, $c, $area_id, @category ) = @_;
+ my $category = join( '/', @category );
+ $c->go( 'council_edit', [ $area_id, $category ] );
+}
+
+sub council_edit : Path('council_edit') : Args(2) {
+ my ( $self, $c, $area_id, $category ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ $c->stash->{area_id} = $area_id;
+
+ $c->forward( 'get_token' );
+ $c->forward('setup_council_details');
+
+ my $contact = $c->model('DB::Contact')->search(
+ {
+ area_id => $area_id,
+ category => $category
+ }
+ )->first;
+
+ $c->stash->{contact} = $contact;
+
+ my $history = $c->model('DB::ContactsHistory')->search(
+ {
+ area_id => $area_id,
+ category => $category
+ },
+ {
+ order_by => ['contacts_history_id']
+ },
+ );
+
+ $c->stash->{history} = $history;
+
+ return 1;
+}
+
+sub search_reports : Path('search_reports') {
+ my ( $self, $c ) = @_;
+
+ $c->forward('check_page_allowed');
+
+ if (my $search = $c->req->param('search')) {
+ $c->stash->{searched} = 1;
+
+ my ( $site_res_sql, $site_key, $site_restriction ) = $c->cobrand->site_restriction;
+
+ my $search_n = 0;
+ $search_n = int($search) if $search =~ /^\d+$/;
+
+ my $like_search = "%$search%";
+
+ # when DBIC creates the join it does 'JOIN users user' in the
+ # SQL which makes PostgreSQL unhappy as user is a reserved
+ # word, hence we need to quote this SQL. However, the quoting
+ # makes PostgreSQL unhappy elsewhere so we only want to do
+ # it for this query and then switch it off afterwards.
+ $c->model('DB')->schema->storage->sql_maker->quote_char( '"' );
+
+ my $problems = $c->cobrand->problems->search(
+ {
+ -or => [
+ 'me.id' => $search_n,
+ 'user.email' => { ilike => $like_search },
+ 'me.name' => { ilike => $like_search },
+ title => { ilike => $like_search },
+ detail => { ilike => $like_search },
+ council => { like => $like_search },
+ cobrand_data => { like => $like_search },
+ ]
+ },
+ {
+ prefetch => 'user',
+ order_by => [\"(state='hidden')",'created']
+ }
+ );
+
+ $c->stash->{problems} = [ $problems->all ];
+
+
+ $c->stash->{edit_council_contacts} = 1
+ if ( grep {$_ eq 'councilcontacts'} keys %{$c->stash->{allowed_pages}});
+
+ my $updates = $c->model('DB::Comment')->search(
+ {
+ -or => [
+ 'me.id' => $search_n,
+ 'problem.id' => $search_n,
+ 'user.email' => { ilike => $like_search },
+ 'me.name' => { ilike => $like_search },
+ text => { ilike => $like_search },
+ 'me.cobrand_data' => { ilike => $like_search },
+ %{ $site_restriction },
+ ]
+ },
+ {
+ -select => [ 'me.*', qw/problem.council problem.state/ ],
+ prefetch => [qw/user problem/],
+ order_by => [\"(me.state='hidden')",\"(problem.state='hidden')",'me.created']
+ }
+ );
+
+ $c->stash->{updates} = [ $updates->all ];
+
+ # Switch quoting back off. See above for explanation of this.
+ $c->model('DB')->schema->storage->sql_maker->quote_char( '' );
+ }
+}
+
+sub report_edit : Path('report_edit') : Args(1) {
+ my ( $self, $c, $id ) = @_;
+
+ my ( $site_res_sql, $site_key, $site_restriction ) = $c->cobrand->site_restriction;
+
+ my $problem = $c->cobrand->problems->search(
+ {
+ id => $id,
+ }
+ )->first;
+
+ $c->detach( '/page_error_404_not_found',
+ [ _('The requested URL was not found on this server.') ] )
+ unless $problem;
+
+ $c->stash->{problem} = $problem;
+
+ $c->forward('get_token');
+ $c->forward('check_page_allowed');
+
+ $c->stash->{updates} =
+ [ $c->model('DB::Comment')
+ ->search( { problem_id => $problem->id }, { order_by => 'created' } )
+ ->all ];
+
+ if ( $c->req->param('resend') ) {
+ $c->forward('check_token');
+
+ $problem->whensent(undef);
+ $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->req->param('submit') ) {
+ $c->forward('check_token');
+
+ my $done = 0;
+ my $edited = 0;
+
+ my $new_state = $c->req->param('state');
+ my $old_state = $problem->state;
+ if ( $new_state eq 'confirmed'
+ && $problem->state eq 'unconfirmed'
+ && $c->cobrand->moniker eq 'emptyhomes' )
+ {
+ $c->stash->{status_message} =
+ '<p><em>'
+ . _('I am afraid you cannot confirm unconfirmed reports.')
+ . '</em></p>';
+ $done = 1;
+ }
+
+ # do this here so before we update the values in problem
+ if ( $c->req->param('anonymous') ne $problem->anonymous
+ || $c->req->param('name') ne $problem->name
+ || $c->req->param('email') ne $problem->user->email
+ || $c->req->param('title') ne $problem->title
+ || $c->req->param('detail') ne $problem->detail )
+ {
+ $edited = 1;
+ }
+
+ $problem->anonymous( $c->req->param('anonymous') );
+ $problem->title( $c->req->param('title') );
+ $problem->detail( $c->req->param('detail') );
+ $problem->state( $c->req->param('state') );
+ $problem->name( $c->req->param('name') );
+
+ if ( $c->req->param('email') ne $problem->user->email ) {
+ my $user = $c->model('DB::User')->find_or_create(
+ { email => $c->req->param('email') }
+ );
+
+ $user->insert unless $user->in_storage;
+ $problem->user( $user );
+ }
+
+ if ( $c->req->param('remove_photo') ) {
+ $problem->photo(undef);
+ }
+
+ if ( $new_state ne $old_state ) {
+ $problem->lastupdate( \'ms_current_timestamp()' );
+ }
+
+ if ( $new_state eq 'confirmed' and $old_state eq 'unconfirmed' ) {
+ $problem->confirmed( \'ms_current_timestamp()' );
+ }
+
+ if ($done) {
+ $problem->discard_changes;
+ }
+ else {
+ $problem->update;
+
+ if ( $new_state ne $old_state ) {
+ $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] );
+ }
+ if ($edited) {
+ $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;
+ }
+ }
+
+ return 1;
+}
+
+=head2 set_allowed_pages
+
+Sets up the allowed_pages stash entry for checking if the current page is
+available in the current cobrand.
+
+=cut
+
+sub set_allowed_pages : Private {
+ my ( $self, $c ) = @_;
+
+ my $pages = $c->cobrand->admin_pages;
+
+ if( !$pages ) {
+ $pages = {
+ 'summary' => [_('Summary'), 0],
+ 'council_list' => [_('Council contacts'), 1],
+ 'search_reports' => [_('Search Reports'), 2],
+ 'timeline' => [_('Timeline'), 3],
+ 'questionnaire' => [_('Survey Results'), 4],
+ 'council_contacts' => [undef, undef],
+ 'council_edit' => [undef, undef],
+ 'report_edit' => [undef, undef],
+ 'update_edit' => [undef, undef],
+ }
+ }
+
+ my @allowed_links = sort {$pages->{$a}[1] <=> $pages->{$b}[1]} grep {$pages->{$_}->[0] } keys %$pages;
+
+ $c->stash->{allowed_pages} = $pages;
+ $c->stash->{allowed_links} = \@allowed_links;
+
+ return 1;
+}
+
+=item get_token
+
+Generate a token based on user and secret
+
+=cut
+
+sub get_token : Private {
+ my ( $self, $c ) = @_;
+
+ my $secret = $c->model('DB::Secret')->search()->first;
+
+ my $user = $c->req->remote_user();
+ $user ||= '';
+
+ my $token = md5_hex(($user . $secret->secret));
+
+ $c->stash->{token} = $token;
+
+ return 1;
+}
+
+=item check_token
+
+Check that a token has been set on a request and it's the correct token. If
+not then display 404 page
+
+=cut
+
+sub check_token : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->req->param('token' ) ne $c->stash->{token} ) {
+ $c->detach( '/page_error_404_not_found', [ _('The requested URL was not found on this server.') ] );
+ }
+
+ return 1;
+}
+
+=item log_edit
+
+ $c->forward( 'log_edit', [ $object_id, $object_type, $action_performed ] );
+
+Adds an entry into the admin_log table using the current remote_user.
+
+=cut
+
+sub log_edit : Private {
+ my ( $self, $c, $id, $object_type, $action ) = @_;
+ $c->model('DB::AdminLog')->create(
+ {
+ admin_user => ( $c->req->remote_user() || '' ),
+ object_type => $object_type,
+ action => $action,
+ object_id => $id,
+ }
+ )->insert();
+}
+
+sub update_edit : Path('update_edit') : Args(1) {
+ my ( $self, $c, $id ) = @_;
+
+ my ( $site_res_sql, $site_key, $site_restriction ) =
+ $c->cobrand->site_restriction;
+ my $update = $c->model('DB::Comment')->search(
+ {
+ id => $id,
+ %{$site_restriction},
+ }
+ )->first;
+
+ $c->detach( '/page_error_404_not_found',
+ [ _('The requested URL was not found on this server.') ] )
+ unless $update;
+
+ $c->forward('get_token');
+ $c->forward('check_page_allowed');
+
+ $c->stash->{update} = $update;
+
+ my $status_message = '';
+ if ( $c->req->param('submit') ) {
+ $c->forward('check_token');
+
+ my $old_state = $update->state;
+ my $new_state = $c->req->param('state');
+
+ my $edited = 0;
+
+ # $update->name can be null which makes ne unhappy
+ my $name = $update->name || '';
+
+ if ( $c->req->param('name') ne $name
+ || $c->req->param('email') ne $update->user->email
+ || $c->req->param('anonymous') ne $update->anonymous
+ || $c->req->param('text') ne $update->text ){
+ $edited = 1;
+ }
+
+ if ( $c->req->param('remove_photo') ) {
+ $update->photo(undef);
+ }
+
+ $update->name( $c->req->param('name') || '' );
+ $update->text( $c->req->param('text') );
+ $update->anonymous( $c->req->param('anonymous') );
+ $update->state( $c->req->param('state') );
+
+ if ( $c->req->param('email') ne $update->user->email ) {
+ my $user =
+ $c->model('DB::User')
+ ->find_or_create( { email => $c->req->param('email') } );
+
+ $user->insert unless $user->in_storage;
+ $update->user($user);
+ }
+
+ $update->update;
+
+ $status_message = '<p><em>' . _('Updated!') . '</em></p>';
+
+ # If we're hiding an update, see if it marked as fixed and unfix if so
+ if ( $new_state eq 'hidden' && $update->mark_fixed ) {
+ if ( $update->problem->state eq 'fixed' ) {
+ $update->problem->state('confirmed');
+ $update->problem->update;
+ }
+
+ $status_message .=
+ '<p><em>' . _('Problem marked as open.') . '</em></p>';
+ }
+
+ if ( $new_state ne $old_state ) {
+ $c->forward( 'log_edit',
+ [ $update->id, 'update', 'state_change' ] );
+ }
+
+ if ($edited) {
+ $c->forward( 'log_edit', [ $update->id, 'update', 'edit' ] );
+ }
+
+ }
+ $c->stash->{status_message} = $status_message;
+
+ return 1;
+}
+
+sub check_page_allowed : Private {
+ my ( $self, $c ) = @_;
+
+ $c->forward('set_allowed_pages');
+
+ (my $page = $c->req->action) =~ s#admin/?##;
+
+ $page ||= 'summary';
+
+ if ( !grep { $_ eq $page } keys %{ $c->stash->{allowed_pages} } ) {
+ $c->detach( '/page_error_404_not_found', [ _('The requested URL was not found on this server.') ] );
+ }
+
+ return 1;
+}
+
+sub trim {
+ my $self = shift;
+ my $e = shift;
+ $e =~ s/^\s+//;
+ $e =~ s/\s+$//;
+ return $e;
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Alert.pm b/perllib/FixMyStreet/App/Controller/Alert.pm
new file mode 100644
index 000000000..ff92a7d2d
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Alert.pm
@@ -0,0 +1,529 @@
+package FixMyStreet::App::Controller::Alert;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use mySociety::EmailUtil qw(is_valid_email);
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Alert - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=cut
+
+=head2 alert
+
+Show the alerts page
+
+=cut
+
+sub index : Path('') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{cobrand_form_elements} = $c->cobrand->form_elements('alerts');
+
+ unless ( $c->req->referer && $c->req->referer =~ /fixmystreet\.com/ ) {
+ $c->forward( 'add_recent_photos', [10] );
+ }
+}
+
+sub list : Path('list') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ return
+ unless $c->forward('setup_request')
+ && $c->forward('prettify_pc')
+ && $c->forward('determine_location')
+ && $c->forward( 'add_recent_photos', [5] )
+ && $c->forward('setup_council_rss_feeds')
+ && $c->forward('setup_coordinate_rss_feeds');
+}
+
+=head2 subscribe
+
+Target for subscribe form
+
+=cut
+
+sub subscribe : Path('subscribe') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->detach('rss') if $c->req->param('rss');
+
+ # 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 exists $c->req->params->{'rznvy'} || $c->req->params->{'alert'};
+
+ $c->go('updates') if $c->req->params->{'id'};
+
+ # shouldn't get to here but if we have then do something sensible
+ $c->go('index');
+}
+
+=head2 rss
+
+Redirects to relevant RSS feed
+
+=cut
+
+sub rss : Private {
+ my ( $self, $c ) = @_;
+ my $feed = $c->req->params->{feed};
+
+ unless ($feed) {
+ $c->stash->{errors} = [ _('Please select the feed you want') ];
+ $c->go('list');
+ }
+
+ my $url;
+ if ( $feed =~ /^area:(?:\d+:)+(.*)$/ ) {
+ ( my $id = $1 ) =~ tr{:_}{/+};
+ $url = $c->cobrand->base_url() . '/rss/area/' . $id;
+ $c->res->redirect($url);
+ }
+ elsif ( $feed =~ /^(?:council|ward):(?:\d+:)+(.*)$/ ) {
+ ( my $id = $1 ) =~ tr{:_}{/+};
+ $url = $c->cobrand->base_url() . '/rss/reports/' . $id;
+ $c->res->redirect($url);
+ }
+ elsif ( $feed =~ /^local:([\d\.-]+):([\d\.-]+)$/ ) {
+ $url = $c->cobrand->base_url() . '/rss/l/' . $1 . ',' . $2;
+ $c->res->redirect($url);
+ }
+ else {
+ $c->stash->{errors} = [ _('Illegal feed selection') ];
+ $c->go('list');
+ }
+}
+
+=head2 subscribe_email
+
+Sign up to email alerts
+
+=cut
+
+sub subscribe_email : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{errors} = [];
+ $c->forward('process_user');
+
+ my $type = $c->req->param('type');
+ push @{ $c->stash->{errors} }, _('Please select the type of alert you want')
+ if $type && $type eq 'local' && !$c->req->param('feed');
+ if (@{ $c->stash->{errors} }) {
+ $c->go('updates') if $type && $type eq 'updates';
+ $c->go('list') if $type && $type eq 'local';
+ $c->go('index');
+ }
+
+ if ( $type eq 'updates' ) {
+ $c->forward('set_update_alert_options');
+ }
+ elsif ( $type eq 'local' ) {
+ $c->forward('set_local_alert_options');
+ }
+ else {
+ $c->detach( '/page_error_404_not_found', [ 'Invalid type' ] );
+ }
+
+ $c->forward('create_alert');
+ if ( $c->stash->{alert}->confirmed ) {
+ $c->stash->{confirm_type} = 'created';
+ $c->stash->{template} = 'tokens/confirm_alert.html';
+ } else {
+ $c->forward('send_confirmation_email');
+ }
+}
+
+sub updates : Path('updates') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{email} = $c->req->param('rznvy');
+ $c->stash->{problem_id} = $c->req->param('id');
+ $c->stash->{cobrand_form_elements} = $c->cobrand->form_elements('alerts');
+}
+
+=head2 confirm
+
+Confirm signup to or unsubscription from an alert. Forwarded here from Tokens.
+
+=cut
+
+sub confirm : Private {
+ my ( $self, $c ) = @_;
+
+ my $alert = $c->stash->{alert};
+
+ if ( $c->stash->{confirm_type} eq 'subscribe' ) {
+ $alert->confirm();
+ }
+ elsif ( $c->stash->{confirm_type} eq 'unsubscribe' ) {
+ $alert->disable();
+ }
+}
+
+=head2 create_alert
+
+Take the alert options from the stash and use these to create a new
+alert. If it finds an existing alert that's the same then use that
+
+=cut
+
+sub create_alert : Private {
+ my ( $self, $c ) = @_;
+
+ my $options = $c->stash->{alert_options};
+
+ my $alert = $c->model('DB::Alert')->find($options);
+
+ unless ($alert) {
+ $options->{cobrand} = $c->cobrand->moniker();
+ $options->{cobrand_data} = $c->cobrand->extra_update_data();
+ $options->{lang} = $c->stash->{lang_code};
+
+ $alert = $c->model('DB::Alert')->new($options);
+ $alert->insert();
+ }
+
+ $alert->confirm() if $c->user && $c->user->id == $alert->user->id;
+
+ $c->stash->{alert} = $alert;
+}
+
+=head2 set_update_alert_options
+
+Set up the options in the stash required to create a problem update alert
+
+=cut
+
+sub set_update_alert_options : Private {
+ my ( $self, $c ) = @_;
+
+ my $report_id = $c->req->param('id');
+
+ my $options = {
+ user => $c->stash->{alert_user},
+ alert_type => 'new_updates',
+ parameter => $report_id,
+ };
+
+ $c->stash->{alert_options} = $options;
+}
+
+=head2 set_local_alert_options
+
+Set up the options in the stash required to create a local problems alert
+
+=cut
+
+sub set_local_alert_options : Private {
+ my ( $self, $c ) = @_;
+
+ my $feed = $c->req->param('feed');
+
+ my ( $type, @params, $alert );
+ if ( $feed =~ /^area:(?:\d+:)?(\d+)/ ) {
+ $type = 'area_problems';
+ push @params, $1;
+ }
+ elsif ( $feed =~ /^council:(\d+)/ ) {
+ $type = 'council_problems';
+ push @params, $1, $1;
+ }
+ elsif ( $feed =~ /^ward:(\d+):(\d+)/ ) {
+ $type = 'ward_problems';
+ push @params, $1, $2;
+ }
+ elsif ( $feed =~
+ m{ \A local: ( [\+\-]? \d+ \.? \d* ) : ( [\+\-]? \d+ \.? \d* ) }xms )
+ {
+ $type = 'local_problems';
+ push @params, $2, $1; # Note alert parameters are lon,lat
+ }
+
+ my $options = {
+ user => $c->stash->{alert_user},
+ alert_type => $type
+ };
+
+ if ( scalar @params == 1 ) {
+ $options->{parameter} = $params[0];
+ }
+ elsif ( scalar @params == 2 ) {
+ $options->{parameter} = $params[0];
+ $options->{parameter2} = $params[1];
+ }
+
+ $c->stash->{alert_options} = $options;
+}
+
+=head2 send_confirmation_email
+
+Generate a token and send out an alert subscription confirmation email and
+then display confirmation page.
+
+=cut
+
+sub send_confirmation_email : Private {
+ my ( $self, $c ) = @_;
+
+ my $token = $c->model("DB::Token")->create(
+ {
+ scope => 'alert',
+ data => {
+ id => $c->stash->{alert}->id,
+ type => 'subscribe',
+ email => $c->stash->{alert}->user->email
+ }
+ }
+ );
+
+ $c->stash->{token_url} = $c->uri_for_email( '/A', $token->token );
+
+ $c->send_email( 'alert-confirm.txt', { to => $c->stash->{alert}->user->email } );
+
+ $c->stash->{email_type} = 'alert';
+ $c->stash->{template} = 'email_sent.html';
+}
+
+=head2 prettify_pc
+
+This will canonicalise and prettify the postcode and stick a pretty_pc and pretty_pc_text in the stash.
+
+=cut
+
+sub prettify_pc : Private {
+ my ( $self, $c ) = @_;
+
+ my $pretty_pc = $c->req->params->{'pc'};
+
+ if ( mySociety::PostcodeUtil::is_valid_postcode( $c->req->params->{'pc'} ) )
+ {
+ $pretty_pc = mySociety::PostcodeUtil::canonicalise_postcode(
+ $c->req->params->{'pc'} );
+ my $pretty_pc_text = $pretty_pc;
+ $pretty_pc_text =~ s/ //g;
+ $c->stash->{pretty_pc_text} = $pretty_pc_text;
+ }
+
+ $c->stash->{pretty_pc} = $pretty_pc;
+
+ return 1;
+}
+
+=head2 process_user
+
+Fetch/check email address
+
+=cut
+
+sub process_user : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->user_exists ) {
+ $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 { $_ => scalar $c->req->param($_) }
+ ( 'rznvy' ); # , 'password_register' );
+
+ # cleanup the email address
+ my $email = $params{rznvy} ? lc $params{rznvy} : '';
+ $email =~ s{\s+}{}g;
+
+ push @{ $c->stash->{errors} }, _('Please enter a valid email address')
+ unless is_valid_email( $email );
+
+ 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->req->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( Utils::trim_text( $params{password_register} ) );
+}
+
+=head2 setup_coordinate_rss_feeds
+
+Takes the latitide and longitude from the stash and uses them to generate uris
+for the local rss feeds
+
+=cut
+
+sub setup_coordinate_rss_feeds : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{rss_feed_id} =
+ sprintf( 'local:%s:%s', $c->stash->{latitude}, $c->stash->{longitude} );
+
+ my $rss_feed;
+ if ( $c->stash->{pretty_pc_text} ) {
+ $rss_feed = $c->uri_for( "/rss/pc/" . $c->stash->{pretty_pc_text} );
+ }
+ else {
+ $rss_feed = $c->uri_for(
+ sprintf( "/rss/l/%s,%s",
+ $c->stash->{latitude},
+ $c->stash->{longitude} )
+ );
+ }
+
+ $c->stash->{rss_feed_uri} = $rss_feed;
+
+ $c->stash->{rss_feed_2k} = $rss_feed . '/2';
+ $c->stash->{rss_feed_5k} = $rss_feed . '/5';
+ $c->stash->{rss_feed_10k} = $rss_feed . '/10';
+ $c->stash->{rss_feed_20k} = $rss_feed . '/20';
+
+ return 1;
+}
+
+=head2 setup_council_rss_feeds
+
+Generate the details required to display the council/ward/area RSS feeds
+
+=cut
+
+sub setup_council_rss_feeds : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{council_check_action} = 'alert';
+ unless ( $c->forward('/council/load_and_check_councils_and_wards') ) {
+ $c->go('index');
+ }
+
+ ( $c->stash->{options}, $c->stash->{reported_to_options} ) =
+ $c->cobrand->council_rss_alert_options( $c->stash->{all_councils}, $c );
+
+ return 1;
+}
+
+=head2 determine_location
+
+Do all the things we need to do to work out where the alert is for
+and to setup the location related things for later
+
+=cut
+
+sub determine_location : Private {
+ my ( $self, $c ) = @_;
+
+ # Try to create a location for whatever we have
+ unless ( $c->forward('/location/determine_location_from_coords')
+ || $c->forward('/location/determine_location_from_pc') )
+ {
+
+ if ( $c->stash->{possible_location_matches} ) {
+ $c->stash->{choose_target_uri} = $c->uri_for('/alert/list');
+ $c->detach('choose');
+ }
+
+ $c->go('index') if $c->stash->{location_error};
+ }
+
+ # truncate the lat,lon for nicer urls
+ ( $c->stash->{latitude}, $c->stash->{longitude} ) =
+ map { Utils::truncate_coordinate($_) }
+ ( $c->stash->{latitude}, $c->stash->{longitude} );
+
+ my $dist =
+ mySociety::Gaze::get_radius_containing_population( $c->stash->{latitude},
+ $c->stash->{longitude}, 200000 );
+ $dist = int( $dist * 10 + 0.5 );
+ $dist = $dist / 10.0;
+ $c->stash->{population_radius} = $dist;
+
+ return 1;
+}
+
+=head2 add_recent_photos
+
+ $c->forward( 'add_recent_photos', [ $num_photos ] );
+
+Adds the most recent $num_photos to the template. If there is coordinate
+and population radius information in the stash uses that to limit it.
+
+=cut
+
+sub add_recent_photos : Private {
+ my ( $self, $c, $num_photos ) = @_;
+
+ if ( $c->stash->{latitude}
+ and $c->stash->{longitude}
+ and $c->stash->{population_radius} )
+ {
+
+ $c->stash->{photos} = $c->cobrand->recent_photos(
+ $num_photos,
+ $c->stash->{latitude},
+ $c->stash->{longitude},
+ $c->stash->{population_radius}
+ );
+ }
+ else {
+ $c->stash->{photos} = $c->cobrand->recent_photos($num_photos);
+ }
+
+ return 1;
+}
+
+sub choose : Private {
+ my ( $self, $c ) = @_;
+ $c->stash->{template} = 'alert/choose.html';
+}
+
+
+=head2 setup_request
+
+Setup the variables we need for the rest of the request
+
+=cut
+
+sub setup_request : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{rznvy} = $c->req->param('rznvy');
+ $c->stash->{selected_feed} = $c->req->param('feed');
+
+ if ( $c->user ) {
+ $c->stash->{rznvy} ||= $c->user->email;
+ }
+
+ $c->stash->{cobrand_form_elements} = $c->cobrand->form_elements('alerts');
+
+ return 1;
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm
new file mode 100644
index 000000000..fcf91123e
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Around.pm
@@ -0,0 +1,285 @@
+package FixMyStreet::App::Controller::Around;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::Map;
+use List::MoreUtils qw(any);
+use Encode;
+use FixMyStreet::Map;
+use Utils;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Around - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Allow the user to search for reports around a particular location.
+
+=head1 METHODS
+
+=head2 around
+
+Find the location search and display nearby reports (for pc or lat,lon).
+
+For x,y searches convert to lat,lon and 301 redirect to them.
+
+If no search redirect back to the homepage.
+
+=cut
+
+sub around_index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ # handle old coord systems
+ $c->forward('redirect_en_or_xy_to_latlon');
+
+ # Check if we have a partial report
+ my $partial_report = $c->forward('load_partial');
+
+ # Try to create a location for whatever we have
+ return
+ unless $c->forward('/location/determine_location_from_coords')
+ || $c->forward('/location/determine_location_from_pc');
+
+ # Check to see if the spot is covered by a council - if not show an error.
+ return unless $c->forward('check_location_is_acceptable');
+
+ # If we have a partial - redirect to /report/new so that it can be
+ # completed.
+ if ($partial_report) {
+ my $new_uri = $c->uri_for(
+ '/report/new',
+ {
+ partial => $c->stash->{partial_token}->token,
+ latitude => $c->stash->{latitude},
+ longitude => $c->stash->{longitude},
+ pc => $c->stash->{pc},
+ }
+ );
+ return $c->res->redirect($new_uri);
+ }
+
+ # Show the nearby reports
+ $c->detach('display_location');
+}
+
+=head2 redirect_en_or_xy_to_latlon
+
+ # detaches if there was a redirect
+ $c->forward('redirect_en_or_xy_to_latlon');
+
+Handle coord systems that are no longer in use.
+
+=cut
+
+sub redirect_en_or_xy_to_latlon : Private {
+ my ( $self, $c ) = @_;
+ my $req = $c->req;
+
+ # check for x,y or e,n requests
+ my $x = $req->param('x');
+ my $y = $req->param('y');
+ my $e = $req->param('e');
+ my $n = $req->param('n');
+
+ # lat and lon - fill in below if we need to
+ my ( $lat, $lon );
+
+ if ( $x || $y ) {
+ ( $lat, $lon ) = FixMyStreet::Map::tile_xy_to_wgs84( $x, $y );
+ ( $lat, $lon ) = map { Utils::truncate_coordinate($_) } ( $lat, $lon );
+ }
+ elsif ( $e || $n ) {
+ ( $lat, $lon ) = Utils::convert_en_to_latlon_truncated( $e, $n );
+ }
+ else {
+ return;
+ }
+
+ # create a uri and redirect to it
+ my $ll_uri = $c->uri_for( '/around', { lat => $lat, lon => $lon } );
+ $c->res->redirect( $ll_uri, 301 );
+ $c->detach;
+}
+
+=head2 load_partial
+
+ my $partial_report = $c->forward('load_partial');
+
+Check for the partial token and load the partial report. If found save it and
+token to stash and return report. Otherwise return false.
+
+=cut
+
+sub load_partial : Private {
+ my ( $self, $c ) = @_;
+
+ my $partial = scalar $c->req->param('partial')
+ || return;
+
+ # is it in the database
+ my $token =
+ $c->model("DB::Token")
+ ->find( { scope => 'partial', token => $partial } ) #
+ || last;
+
+ # can we get an id from it?
+ my $report_id = $token->data #
+ || last;
+
+ # load the related problem
+ my $report = $c->cobrand->problems #
+ ->search( { id => $report_id, state => 'partial' } ) #
+ ->first
+ || last;
+
+ # save what we found on the stash.
+ $c->stash->{partial_token} = $token;
+ $c->stash->{partial_report} = $report;
+
+ return $report;
+}
+
+=head2 display_location
+
+Display a specific lat/lng location (which may have come from a pc search).
+
+=cut
+
+sub display_location : Private {
+ my ( $self, $c ) = @_;
+
+ # set the template to use
+ $c->stash->{template} = 'around/display_location.html';
+
+ # get the lat,lng
+ my $latitude = $c->stash->{latitude};
+ my $longitude = $c->stash->{longitude};
+
+ # truncate the lat,lon for nicer rss urls, and strings for outputting
+ my $short_latitude = Utils::truncate_coordinate($latitude);
+ my $short_longitude = Utils::truncate_coordinate($longitude);
+ $c->stash->{short_latitude} = $short_latitude;
+ $c->stash->{short_longitude} = $short_longitude;
+
+ # Deal with pin hiding/age
+ my $all_pins = $c->req->param('all_pins') ? 1 : undef;
+ $c->stash->{all_pins} = $all_pins;
+ my $interval = $all_pins ? undef : $c->cobrand->on_map_default_max_pin_age;
+
+ # get the map features
+ my ( $on_map_all, $on_map, $around_map, $distance ) =
+ FixMyStreet::Map::map_features( $c, $latitude, $longitude,
+ $interval );
+
+ # copy the found reports to the stash
+ $c->stash->{on_map} = $on_map;
+ $c->stash->{around_map} = $around_map;
+ $c->stash->{distance} = $distance;
+
+ # create a list of all the pins
+ my @pins;
+ unless ($c->req->param('no_pins')) {
+ @pins = map {
+ # Here we might have a DB::Problem or a DB::Nearby, we always want the problem.
+ my $p = (ref $_ eq 'FixMyStreet::App::Model::DB::Nearby') ? $_->problem : $_;
+ {
+ latitude => $p->latitude,
+ longitude => $p->longitude,
+ colour => $p->state eq 'fixed' ? 'green' : 'red',
+ id => $p->id,
+ title => $p->title,
+ }
+ } @$on_map_all, @$around_map;
+ }
+
+ $c->stash->{page} = 'around'; # So the map knows to make clickable pins, update on pan
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => $latitude,
+ longitude => $longitude,
+ clickable => 1,
+ pins => \@pins,
+ );
+
+ return 1;
+}
+
+=head2 check_location_is_acceptable
+
+Find the lat and lon in stash and check that they are acceptable to the council,
+and that they are in UK (if we are in UK).
+
+=cut
+
+sub check_location_is_acceptable : Private {
+ my ( $self, $c ) = @_;
+
+ # check that there are councils that can accept this location
+ $c->stash->{council_check_action} = 'submit_problem';
+ $c->stash->{remove_redundant_councils} = 1;
+ return $c->forward('/council/load_and_check_councils');
+}
+
+=head2 /ajax
+
+Handle the ajax calls that the map makes when it is dragged. The info returned
+is used to update the pins on the map and the text descriptions on the side of
+the map.
+
+=cut
+
+sub ajax : Path('/ajax') {
+ my ( $self, $c ) = @_;
+
+ # how far back should we go?
+ my $all_pins = $c->req->param('all_pins') ? 1 : undef;
+ my $interval = $all_pins ? undef : $c->cobrand->on_map_default_max_pin_age;
+
+ # Need to be the class that can handle it
+ if ($c->req->param('bbox')) {
+ FixMyStreet::Map::set_map_class( 'OSM' );
+ }
+
+ # extract the data from the map
+ my ( $pins, $on_map, $around_map, $dist ) =
+ FixMyStreet::Map::map_pins( $c, $interval );
+
+ # render templates to get the html
+ my $on_map_list_html =
+ $c->view('Web')
+ ->render( $c, 'around/on_map_list_items.html', { on_map => $on_map } );
+
+ my $around_map_list_html = $c->view('Web')->render(
+ $c,
+ 'around/around_map_list_items.html',
+ { around_map => $around_map, dist => $dist }
+ );
+
+ # JSON encode the response
+ my $body = JSON->new->utf8(1)->pretty(1)->encode(
+ {
+ pins => $pins,
+ current => $on_map_list_html,
+ current_near => $around_map_list_html,
+ }
+ );
+
+ # assume this is not cacheable - may need to be more fine-grained later
+ $c->res->content_type('text/javascript; charset=utf-8');
+ $c->res->header( 'Cache_Control' => 'max-age=0' );
+
+ if ( $c->req->param('bbox') ) {
+ $c->res->body($body);
+ } else {
+ # The JS needs the surrounding brackets for Tilma
+ $c->res->body("($body)");
+ }
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm
new file mode 100644
index 000000000..c67de692a
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Auth.pm
@@ -0,0 +1,283 @@
+package FixMyStreet::App::Controller::Auth;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use Email::Valid;
+use Net::Domain::TLD;
+use mySociety::AuthToken;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Auth - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Controller for all the authentication related pages - create account, sign in,
+sign out.
+
+=head1 METHODS
+
+=head2 index
+
+Present the user with a sign in / create account page.
+
+=cut
+
+sub general : Path : Args(0) {
+ my ( $self, $c ) = @_;
+ my $req = $c->req;
+
+ $c->detach( 'redirect_on_signin', [ $req->param('r') ] )
+ if $c->user && $req->param('r');
+
+ # all done unless we have a form posted to us
+ return unless $req->method eq 'POST';
+
+ # decide which action to take
+ $c->detach('email_sign_in') if $req->param('email_sign_in')
+ || $c->req->param('name') || $c->req->param('password_register');
+
+ $c->forward( 'sign_in' )
+ && $c->detach( 'redirect_on_signin', [ $req->param('r') ] );
+
+}
+
+=head2 sign_in
+
+Allow the user to sign in with a username and a password.
+
+=cut
+
+sub sign_in : Private {
+ my ( $self, $c, $email ) = @_;
+
+ $email ||= $c->req->param('email') || '';
+ my $password = $c->req->param('password_sign_in') || '';
+ my $remember_me = $c->req->param('remember_me') || 0;
+
+ # Sign out just in case
+ $c->logout();
+
+ if ( $email
+ && $password
+ && $c->authenticate( { email => $email, password => $password } ) )
+ {
+
+ # unless user asked to be remembered limit the session to browser
+ $c->set_session_cookie_expire(0)
+ unless $remember_me;
+
+ return 1;
+ }
+
+ $c->stash(
+ sign_in_error => 1,
+ email => $email,
+ remember_me => $remember_me,
+ );
+ return;
+}
+
+=head2 email_sign_in
+
+Email the user the details they need to sign in. Don't check for an account - if
+there isn't one we can create it when they come back with a token (which
+contains the email addresss).
+
+=cut
+
+sub email_sign_in : Private {
+ my ( $self, $c ) = @_;
+
+ # check that the email is valid - otherwise flag an error
+ my $raw_email = lc( $c->req->param('email') || '' );
+
+ my $email_checker = Email::Valid->new(
+ -mxcheck => 1,
+ -tldcheck => 1,
+ -fqdn => 1,
+ );
+
+ my $good_email = $email_checker->address($raw_email);
+ if ( !$good_email ) {
+ $c->stash->{email} = $raw_email;
+ $c->stash->{email_error} =
+ $raw_email ? $email_checker->details : 'missing';
+ return;
+ }
+
+ my $user_params = {};
+ $user_params->{password} = $c->req->param('password_register')
+ if $c->req->param('password_register');
+ my $user = $c->model('DB::User')->new( $user_params );
+
+ my $token_obj = $c->model('DB::Token') #
+ ->create(
+ {
+ scope => 'email_sign_in',
+ data => {
+ email => $good_email,
+ r => $c->req->param('r'),
+ name => $c->req->param('name'),
+ password => $user->password,
+ }
+ }
+ );
+
+ $c->stash->{token} = $token_obj->token;
+ $c->send_email( 'login.txt', { to => $good_email } );
+ $c->stash->{template} = 'auth/token.html';
+}
+
+=head2 token
+
+Handle the 'email_sign_in' tokens. Find the account for the email address
+(creating if needed), authenticate the user and delete the token.
+
+=cut
+
+sub token : Path('/M') : Args(1) {
+ my ( $self, $c, $url_token ) = @_;
+
+ # retrieve the token or return
+ my $token_obj = $url_token
+ ? $c->model('DB::Token')->find( {
+ scope => 'email_sign_in', token => $url_token
+ } )
+ : undef;
+
+ if ( !$token_obj ) {
+ $c->stash->{token_not_found} = 1;
+ return;
+ }
+
+ # Sign out in case we are another user
+ $c->logout();
+
+ # get the email and scrap the token
+ my $data = $token_obj->data;
+ $token_obj->delete;
+
+ # find or create the user related to the token.
+ my $user = $c->model('DB::User')->find_or_create( { email => $data->{email} } );
+ $user->name( $data->{name} ) if $data->{name};
+ $user->password( $data->{password}, 1 ) if $data->{password};
+ $user->update;
+ $c->authenticate( { email => $user->email }, 'no_password' );
+
+ # send the user to their page
+ $c->detach( 'redirect_on_signin', [ $data->{r} ] );
+}
+
+=head2 redirect_on_signin
+
+Used after signing in to take the person back to where they were.
+
+=cut
+
+
+sub redirect_on_signin : Private {
+ my ( $self, $c, $redirect ) = @_;
+ $redirect = 'my' unless $redirect;
+ $c->res->redirect( $c->uri_for( "/$redirect" ) );
+}
+
+=head2 redirect
+
+Used when trying to view a page that requires sign in when you're not.
+
+=cut
+
+sub redirect : Private {
+ my ( $self, $c ) = @_;
+
+ my $uri = $c->uri_for( '/auth', { r => $c->req->path } );
+ $c->res->redirect( $uri );
+ $c->detach;
+
+}
+
+=head2 change_password
+
+Let the user change their password.
+
+=cut
+
+sub change_password : Local {
+ my ( $self, $c ) = @_;
+
+ $c->detach( 'redirect' ) unless $c->user;
+
+ # FIXME - CSRF check here
+ # FIXME - minimum criteria for passwords (length, contain number, etc)
+
+ # If not a post then no submission
+ return unless $c->req->method eq 'POST';
+
+ # get the passwords
+ my $new = $c->req->param('new_password') // '';
+ my $confirm = $c->req->param('confirm') // '';
+
+ # check for errors
+ my $password_error =
+ !$new && !$confirm ? 'missing'
+ : $new ne $confirm ? 'mismatch'
+ : '';
+
+ if ($password_error) {
+ $c->stash->{password_error} = $password_error;
+ $c->stash->{new_password} = $new;
+ $c->stash->{confirm} = $confirm;
+ return;
+ }
+
+ # we should have a usable password - save it to the user
+ $c->user->obj->update( { password => $new } );
+ $c->stash->{password_changed} = 1;
+
+}
+
+=head2 sign_out
+
+Log the user out. Tell them we've done so.
+
+=cut
+
+sub sign_out : Local {
+ my ( $self, $c ) = @_;
+ $c->logout();
+}
+
+=head2 check_auth
+
+Utility page - returns a simple message 'OK' and a 200 response if the user is
+authenticated and a 'Unauthorized' / 401 reponse if they are not.
+
+Mainly intended for testing but might also be useful for ajax calls.
+
+=cut
+
+sub check_auth : Local {
+ my ( $self, $c ) = @_;
+
+ # choose the response
+ my ( $body, $code ) #
+ = $c->user
+ ? ( 'OK', 200 )
+ : ( 'Unauthorized', 401 );
+
+ # set the response
+ $c->res->body($body);
+ $c->res->code($code);
+
+ # NOTE - really a 401 response should also contain a 'WWW-Authenticate'
+ # header but we ignore that here. The spec is not keeping up with usage.
+
+ return;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm
new file mode 100644
index 000000000..88ac4987f
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Contact.pm
@@ -0,0 +1,237 @@
+package FixMyStreet::App::Controller::Contact;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Contact - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Contact us page
+
+=head1 METHODS
+
+=cut
+
+=head2 index
+
+Display contact us page
+
+=cut
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ return
+ unless $c->forward('setup_request')
+ && $c->forward('determine_contact_type');
+}
+
+=head2 submit
+
+Handle contact us form submission
+
+=cut
+
+sub submit : Path('submit') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ return
+ unless $c->forward('setup_request')
+ && $c->forward('determine_contact_type')
+ && $c->forward('validate')
+ && $c->forward('prepare_params_for_email')
+ && $c->forward('send_email');
+}
+
+=head2 determine_contact_type
+
+Work out if we have got here via a report/update or this is a
+generic contact request and set up things accordingly
+
+=cut
+
+sub determine_contact_type : Private {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->req->param('id');
+ my $update_id = $c->req->param('update_id');
+ $id = undef unless $id && $id =~ /^[1-9]\d*$/;
+ $update_id = undef unless $update_id && $update_id =~ /^[1-9]\d*$/;
+
+ if ($id) {
+
+ $c->forward( '/report/load_problem_or_display_error', [ $id ] );
+
+ if ($update_id) {
+ my $update = $c->model('DB::Comment')->find(
+ { id => $update_id }
+ );
+
+ $c->stash->{update} = $update;
+ }
+ }
+
+ return 1;
+}
+
+=head2 validate
+
+Validate the form submission parameters. Sets error messages and redirect
+to index page if errors.
+
+=cut
+
+sub validate : Private {
+ my ( $self, $c ) = @_;
+
+ my ( %field_errors, @errors );
+ my %required = (
+ name => _('Please enter your name'),
+ em => _('Please enter your email'),
+ subject => _('Please enter a subject'),
+ message => _('Please write a message')
+ );
+
+ foreach my $field ( keys %required ) {
+ $field_errors{$field} = $required{$field}
+ unless $c->req->param($field) =~ /\S/;
+ }
+
+ unless ( $field_errors{em} ) {
+ $field_errors{em} = _('Please enter a valid email address')
+ if !mySociety::EmailUtil::is_valid_email( $c->req->param('em') );
+ }
+
+ push @errors, _('Illegal ID')
+ if $c->req->param('id') && $c->req->param('id') !~ /^[1-9]\d*$/
+ or $c->req->param('update_id')
+ && $c->req->param('update_id') !~ /^[1-9]\d*$/;
+
+ unshift @errors,
+ _('There were problems with your report. Please see below.')
+ if scalar keys %field_errors;
+
+ if ( @errors or scalar keys %field_errors ) {
+ $c->stash->{errors} = \@errors;
+ $c->stash->{field_errors} = \%field_errors;
+ $c->go('index');
+ }
+
+ return 1;
+}
+
+=head2 prepare_params_for_email
+
+Does neccessary reformating of exiting params and add any additional
+information required for emailing ( problem ids, admin page links etc )
+
+=cut
+
+sub prepare_params_for_email : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{message} =~ s/\r\n/\n/g;
+ $c->stash->{subject} =~ s/\r|\n/ /g;
+
+ my $base_url = $c->cobrand->base_url_for_emails( $c->cobrand->extra_data );
+ my $admin_base_url = $c->cobrand->admin_base_url
+ || 'https://secure.mysociety.org/admin/bci/';
+
+ if ( $c->stash->{update} ) {
+
+ my $problem_url = $base_url . '/report/' . $c->stash->{update}->problem_id
+ . '#update_' . $c->stash->{update}->id;
+ my $admin_url = $admin_base_url . 'update_edit/' . $c->stash->{update}->id;
+ $c->stash->{message} .= sprintf(
+ " \n\n[ Complaint about update %d on report %d - %s - %s ]",
+ $c->stash->{update}->id,
+ $c->stash->{update}->problem_id,
+ $problem_url, $admin_url
+ );
+ }
+ elsif ( $c->stash->{problem} ) {
+
+ my $problem_url = $base_url . '/report/' . $c->stash->{problem}->id;
+ my $admin_url = $admin_base_url . 'report_edit/' . $c->stash->{problem}->id;
+ $c->stash->{message} .= sprintf(
+ " \n\n[ Complaint about report %d - %s - %s ]",
+ $c->stash->{problem}->id,
+ $problem_url, $admin_url
+ );
+ }
+
+ return 1;
+}
+
+=head2 setup_request
+
+Pulls things from request into stash and adds other information
+generally required to stash
+
+=cut
+
+sub setup_request : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{contact_email} = $c->cobrand->contact_email;
+ $c->stash->{contact_email} =~ s/\@/&#64;/;
+
+ for my $param (qw/em subject message/) {
+ $c->stash->{$param} = $c->req->param($param);
+ }
+
+ # name is already used in the stash for the app class name
+ $c->stash->{form_name} = $c->req->param('name');
+
+ return 1;
+}
+
+=head2 send_email
+
+Sends the email
+
+=cut
+
+sub send_email : Private {
+ my ( $self, $c ) = @_;
+
+ my $recipient = $c->cobrand->contact_email();
+ my $recipient_name = $c->cobrand->contact_name();
+
+ $c->stash->{host} = $c->req->header('HOST');
+ $c->stash->{ip} = $c->req->address;
+ $c->stash->{ip} .=
+ $c->req->header('X-Forwarded-For')
+ ? ' ( forwarded from ' . $c->req->header('X-Forwarded-For') . ' )'
+ : '';
+
+ $c->send_email( 'contact.txt', {
+ to => [ [ $recipient, _($recipient_name) ] ],
+ from => [ $c->stash->{em}, $c->stash->{form_name} ],
+ subject => 'FMS message: ' . $c->stash->{subject},
+ });
+
+ # above is always succesful :(
+ $c->stash->{success} = 1;
+
+ return 1;
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Council.pm b/perllib/FixMyStreet/App/Controller/Council.pm
new file mode 100644
index 000000000..35e3d0d11
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Council.pm
@@ -0,0 +1,107 @@
+package FixMyStreet::App::Controller::Council;
+use Moose;
+use namespace::autoclean;
+
+BEGIN {extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Council - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=head2 load_and_check_councils_and_wards
+
+Try to load councils and wards for this location and check that we have at least one. If
+there are no councils then return false.
+
+=cut
+
+sub load_and_check_councils_and_wards : Private {
+ my ( $self, $c ) = @_;
+ my @area_types = ( $c->cobrand->area_types(), @$mySociety::VotingArea::council_child_types );
+ $c->stash->{area_types} = \@area_types;
+ $c->forward('load_and_check_councils');
+}
+
+=head2 load_and_check_councils
+
+Try to load councils for this location and check that we have at least one. If
+there are no councils then return false.
+
+=cut
+
+sub load_and_check_councils : Private {
+ my ( $self, $c ) = @_;
+ my $latitude = $c->stash->{latitude};
+ my $longitude = $c->stash->{longitude};
+
+ # Look up councils and do checks for the point we've got
+ my @area_types;
+ if ( $c->stash->{area_types} and scalar @{ $c->stash->{area_types} } ) {
+ @area_types = @{ $c->stash->{area_types} };
+ } else {
+ @area_types = $c->cobrand->area_types();
+ }
+
+ # TODO: I think we want in_gb_locale around the MaPit line, needs testing
+ my $all_councils;
+ if ( $c->stash->{fetch_all_areas} ) {
+ my %area_types = map { $_ => 1 } @area_types;
+ my $all_areas =
+ mySociety::MaPit::call( 'point', "4326/$longitude,$latitude" );
+ $c->stash->{all_areas} = $all_areas;
+ $all_councils = {
+ map { $_ => $all_areas->{$_} }
+ grep { $area_types{ $all_areas->{$_}->{type} } }
+ keys %$all_areas
+ };
+ } else {
+ $all_councils =
+ mySociety::MaPit::call( 'point', "4326/$longitude,$latitude",
+ type => \@area_types );
+ }
+
+ # Let cobrand do a check
+ my ( $success, $error_msg ) =
+ $c->cobrand->council_check( { all_councils => $all_councils },
+ $c->stash->{council_check_action} );
+ if ( !$success ) {
+ $c->stash->{location_error} = $error_msg;
+ return;
+ }
+
+ # edit hash in-place
+ $c->cobrand->remove_redundant_councils($all_councils) if $c->stash->{remove_redundant_councils};
+
+ # If we don't have any councils we can't accept the report
+ if ( !scalar keys %$all_councils ) {
+ $c->stash->{location_offshore} = 1;
+ return;
+ }
+
+ # all good if we have some councils left
+ $c->stash->{all_councils} = $all_councils;
+ $c->stash->{all_council_names} =
+ [ map { $_->{name} } values %$all_councils ];
+ return 1;
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/JSON.pm b/perllib/FixMyStreet/App/Controller/JSON.pm
new file mode 100644
index 000000000..a89fb3e6c
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/JSON.pm
@@ -0,0 +1,141 @@
+package FixMyStreet::App::Controller::JSON;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use JSON;
+use DateTime;
+use DateTime::Format::ISO8601;
+use List::MoreUtils 'uniq';
+
+=head1 NAME
+
+FixMyStreet::App::Controller::JSON - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Provide information as JSON
+
+=head1 METHODS
+
+=head2 problems
+
+Provide JSON of new/fixed problems in a specified time range
+
+=cut
+
+sub problems : Local {
+ my ( $self, $c, $path_type ) = @_;
+
+ # get the type from the path - this is to deal with the historic url
+ # structure. In futur
+ $path_type ||= '';
+ my $type =
+ $path_type eq 'new' ? 'new_problems'
+ : $path_type eq 'fixed' ? 'fixed_problems'
+ : '';
+
+ # gather the parameters
+ my $start_date = $c->req->param('start_date') || '';
+ my $end_date = $c->req->param('end_date') || '';
+ my $category = $c->req->param('category') || '';
+
+ my $yyyy_mm_dd = qr{^\d{4}-\d\d-\d\d$};
+ if ( $start_date !~ $yyyy_mm_dd
+ || $end_date !~ $yyyy_mm_dd )
+ {
+ $c->stash->{error} = 'Invalid dates supplied';
+ return;
+ }
+
+ # convert the dates to datetimes and trap errors
+ my $iso8601 = DateTime::Format::ISO8601->new;
+ my $start_dt = eval { $iso8601->parse_datetime($start_date); };
+ my $end_dt = eval { $iso8601->parse_datetime($end_date); };
+ unless ( $start_dt && $end_dt ) {
+ $c->stash->{error} = 'Invalid dates supplied';
+ return;
+ }
+
+ # check that the dates are sane
+ if ( $start_dt > $end_dt ) {
+ $c->stash->{error} = 'Start date after end date';
+ return;
+ }
+
+ # check that the type is supported
+ unless ( $type eq 'new_problems' || $type eq 'fixed_problems' ) {
+ $c->stash->{error} = 'Invalid type supplied';
+ return;
+ }
+
+ # query the database
+ my ( $state, $date_col );
+ if ( $type eq 'new_problems' ) {
+ $state = 'confirmed';
+ $date_col = 'confirmed';
+ } elsif ( $type eq 'fixed_problems' ) {
+ $state = 'fixed';
+ $date_col = 'lastupdate';
+ }
+
+ my $one_day = DateTime::Duration->new( days => 1 );
+ my $query = {
+ $date_col => {
+ '>=' => $start_dt,
+ '<=' => $end_dt + $one_day,
+ },
+ state => $state,
+ };
+ $query->{category} = $category if $category;
+ my @problems = $c->cobrand->problems->search( $query, {
+ order_by => { -asc => 'confirmed' },
+ columns => [
+ 'id', 'title', 'council', 'category',
+ 'detail', 'name', 'anonymous', 'confirmed',
+ 'whensent', 'service',
+ 'latitude', 'longitude', 'used_map',
+ 'state', 'lastupdate',
+ ]
+ } );
+
+ my @councils;
+ foreach my $problem (@problems) {
+ $problem->name( '' ) if $problem->anonymous == 1;
+ $problem->service( 'Web interface' ) if $problem->service eq '';
+ if ($problem->council) {
+ (my $council = $problem->council) =~ s/\|.*//g;
+ my @council_ids = split /,/, $council;
+ push(@councils, @council_ids);
+ $problem->council( \@council_ids );
+ }
+ }
+ @councils = uniq @councils;
+ my $areas_info = mySociety::MaPit::call('areas', \@councils);
+ foreach my $problem (@problems) {
+ if ($problem->council) {
+ my @council_names = map { $areas_info->{$_}->{name} } @{$problem->council} ;
+ $problem->council( join(' and ', @council_names) );
+ }
+ }
+
+ @problems = map { { $_->get_columns } } @problems;
+ $c->stash->{response} = \@problems;
+}
+
+sub end : Private {
+ my ( $self, $c ) = @_;
+
+ my $response =
+ $c->stash->{error}
+ ? { error => $c->stash->{error} }
+ : $c->stash->{response};
+
+ $c->res->content_type('application/json; charset=utf-8');
+ $c->res->body( encode_json( $response || {} ) );
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm
new file mode 100644
index 000000000..9f8260768
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Location.pm
@@ -0,0 +1,130 @@
+package FixMyStreet::App::Controller::Location;
+use Moose;
+use namespace::autoclean;
+
+BEGIN {extends 'Catalyst::Controller'; }
+
+use Encode;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Location - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+This is purely an internal controller for keeping all the location finding things in one place
+
+=head1 METHODS
+
+=head2 determine_location_from_coords
+
+Use latitude and longitude if provided in parameters.
+
+=cut
+
+sub determine_location_from_coords : Private {
+ my ( $self, $c ) = @_;
+
+ my $latitude = $c->req->param('latitude') || $c->req->param('lat');
+ my $longitude = $c->req->param('longitude') || $c->req->param('lon');
+
+ if ( defined $latitude && defined $longitude ) {
+ $c->stash->{latitude} = $latitude;
+ $c->stash->{longitude} = $longitude;
+
+ # Also save the pc if there is one
+ if ( my $pc = $c->req->param('pc') ) {
+ $c->stash->{pc} = $pc;
+ }
+
+ return $c->forward( 'check_location' );
+ }
+
+ return;
+}
+
+=head2 determine_location_from_pc
+
+User has searched for a location - try to find it for them.
+
+If one match is found returns true and lat/lng is set.
+
+If several possible matches are found puts an array onto stash so that user can be prompted to pick one and returns false.
+
+If no matches are found returns false.
+
+=cut
+
+sub determine_location_from_pc : Private {
+ my ( $self, $c, $pc ) = @_;
+
+ # check for something to search
+ $pc ||= $c->req->param('pc') || return;
+ $c->stash->{pc} = $pc; # for template
+
+ my ( $latitude, $longitude, $error ) =
+ FixMyStreet::Geocode::lookup( $pc, $c );
+
+ # If we got a lat/lng set to stash and return true
+ if ( defined $latitude && defined $longitude ) {
+ $c->stash->{latitude} = $latitude;
+ $c->stash->{longitude} = $longitude;
+ return $c->forward( 'check_location' );
+ }
+
+ # $error doubles up to return multiple choices by being an array
+ if ( ref($error) eq 'ARRAY' ) {
+ @$error = map {
+ decode_utf8($_);
+ s/, United Kingdom//;
+ s/, UK//;
+ $_;
+ } @$error;
+ $c->stash->{possible_location_matches} = $error;
+ return;
+ }
+
+ # pass errors back to the template
+ $c->stash->{location_error} = $error;
+ return;
+}
+
+=head2 check_location
+
+Just make sure that for UK installs, our co-ordinates are indeed in the UK.
+
+=cut
+
+sub check_location : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->stash->{latitude} && $c->cobrand->country eq 'GB' ) {
+ eval { Utils::convert_latlon_to_en( $c->stash->{latitude}, $c->stash->{longitude} ); };
+ if (my $error = $@) {
+ mySociety::Locale::pop(); # We threw exception, so it won't have happened.
+ $error = _('That location does not appear to be in Britain; please try again.')
+ if $error =~ /of the area covered/;
+ $c->stash->{location_error} = $error;
+ return;
+ }
+ }
+
+ return 1;
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm
new file mode 100644
index 000000000..19b3ffee0
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/My.pm
@@ -0,0 +1,69 @@
+package FixMyStreet::App::Controller::My;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::My - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=cut
+
+=head2 index
+
+=cut
+
+sub my : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->detach( '/auth/redirect' ) unless $c->user;
+
+ my $p_page = $c->req->params->{p} || 1;
+ my $u_page = $c->req->params->{u} || 1;
+
+ my $pins = [];
+ my $problems = {};
+ my $rs = $c->user->problems->search( undef,
+ { rows => 50 } )->page( $p_page );
+
+ while ( my $problem = $rs->next ) {
+ push @$pins, {
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ colour => $problem->state eq 'fixed' ? 'green' : 'red',
+ id => $problem->id,
+ title => $problem->title,
+ };
+ push @{ $problems->{$problem->state} }, $problem;
+ }
+ $c->stash->{problems_pager} = $rs->pager;
+ $c->stash->{problems} = $problems;
+
+ $rs = $c->user->comments->search(
+ { state => 'confirmed' },
+ { rows => 50 } )->page( $u_page );
+ my @updates = $rs->all;
+ $c->stash->{updates} = \@updates;
+ $c->stash->{updates_pager} = $rs->pager;
+
+ $c->stash->{page} = 'my';
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => $pins->[0]{latitude},
+ longitude => $pins->[0]{longitude},
+ pins => $pins,
+ any_zoom => 1,
+ )
+ if @$pins;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm
new file mode 100644
index 000000000..17862aa0a
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Photo.pm
@@ -0,0 +1,103 @@
+package FixMyStreet::App::Controller::Photo;
+use Moose;
+use namespace::autoclean;
+
+BEGIN {extends 'Catalyst::Controller'; }
+
+use DateTime::Format::HTTP;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Photo - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=cut
+
+
+=head2 index
+
+Display a photo
+
+=cut
+
+sub index :Path :Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->req->param('id');
+ my $comment = $c->req->param('c');
+ $c->detach( 'no_photo' ) unless $id || $comment;
+
+ my @photo;
+ if ( $comment ) {
+ @photo = $c->model('DB::Comment')->search( {
+ id => $comment,
+ state => 'confirmed',
+ photo => { '!=', undef },
+ } );
+ } else {
+ # GoogleBot-Image is doing this for some reason?
+ if ( $id =~ m{ ^(\d+) \D .* $ }x ) {
+ return $c->res->redirect( $c->uri_with( { id => $1 } ), 301 );
+ }
+
+ $c->detach( 'no_photo' ) if $id =~ /\D/;
+ @photo = $c->cobrand->problems->search( {
+ id => $id,
+ state => [ 'confirmed', 'fixed', 'partial' ],
+ photo => { '!=', undef },
+ } );
+ }
+
+ $c->detach( 'no_photo' ) unless @photo;
+
+ my $photo = $photo[0]->photo;
+ if ( $c->req->param('tn' ) ) {
+ $photo = _resize( $photo, 'x100' );
+ } elsif ( $c->cobrand->default_photo_resize ) {
+ $photo = _resize( $photo, $c->cobrand->default_photo_resize );
+ }
+
+ my $dt = DateTime->now();
+ $dt->set_year( $dt->year + 1 );
+
+ $c->res->content_type( 'image/jpeg' );
+ $c->res->header( 'expires', DateTime::Format::HTTP->format_datetime( $dt ) );
+ $c->res->body( $photo );
+}
+
+sub no_photo : Private {
+ my ( $self, $c ) = @_;
+ $c->detach( '/page_error_404_not_found', [ 'No photo' ] );
+}
+
+sub _resize {
+ my ($photo, $size) = @_;
+ use Image::Magick;
+ my $image = Image::Magick->new;
+ $image->BlobToImage($photo);
+ my $err = $image->Scale(geometry => "$size>");
+ throw Error::Simple("resize failed: $err") if "$err";
+ my @blobs = $image->ImageToBlob();
+ undef $image;
+ return $blobs[0];
+}
+
+=head1 AUTHOR
+
+Struan Donald
+
+=head1 LICENSE
+
+This library is free software. You can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
new file mode 100755
index 000000000..acb1628cf
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
@@ -0,0 +1,325 @@
+package FixMyStreet::App::Controller::Questionnaire;
+
+use Moose;
+use namespace::autoclean;
+use Path::Class;
+use Utils;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Questionnaire - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Deals with report questionnaires.
+
+=head1 METHODS
+
+=cut
+
+=head2 check_questionnaire
+
+Checks the questionnaire still needs answering and is in the right state. Also
+finds out if this user has answered the "ever reported" question before.
+
+=cut
+
+sub check_questionnaire : Private {
+ my ( $self, $c ) = @_;
+
+ my $questionnaire = $c->stash->{questionnaire};
+
+ my $problem_id = $questionnaire->problem_id;
+
+ if ( $questionnaire->whenanswered ) {
+ my $problem_url = $c->uri_for( "/report/$problem_id" );
+ my $contact_url = $c->uri_for( "/contact" );
+ $c->stash->{message} = sprintf(_("You have already answered this questionnaire. If you have a question, please <a href='%s'>get in touch</a>, or <a href='%s'>view your problem</a>.\n"), $contact_url, $problem_url);
+ $c->stash->{template} = 'errors/generic.html';
+ $c->detach;
+ }
+
+ unless ( $questionnaire->problem->state eq 'confirmed' || $questionnaire->problem->state eq 'fixed' ) {
+ $c->detach('missing_problem');
+ }
+
+ $c->stash->{problem} = $questionnaire->problem;
+ $c->stash->{answered_ever_reported} = $questionnaire->problem->user->answered_ever_reported;
+
+ # EHA needs to know how many to alter display, and whether to send another or not
+ if ($c->cobrand->moniker eq 'emptyhomes') {
+ $c->stash->{num_questionnaire} = $c->model('DB::Questionnaire')->count(
+ { problem_id => $c->stash->{problem}->id }
+ );
+ }
+
+}
+
+=head2 submit
+
+If someone submits a questionnaire - either a full style one (when we'll have a
+token), or the mini own-report one (when we'll have a problem ID).
+
+=cut
+
+sub submit : Path('submit') {
+ my ( $self, $c ) = @_;
+
+ if ( $c->req->params->{token} ) {
+ $c->forward('submit_standard');
+ } elsif ( $c->req->params->{problem} ) {
+ $c->forward('submit_creator_fixed');
+ } else {
+ $c->detach( '/page_error_404_not_found' );
+ }
+
+ return 1;
+}
+
+=head2 missing_problem
+
+Display couldn't locate problem error message
+
+=cut
+
+sub missing_problem : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{message} = _("I'm afraid we couldn't locate your problem in the database.\n");
+ $c->stash->{template} = 'errors/generic.html';
+}
+
+sub submit_creator_fixed : Private {
+ my ( $self, $c ) = @_;
+
+ my @errors;
+
+ map { $c->stash->{$_} = $c->req->params->{$_} || '' } qw(reported problem);
+
+ # should only be able to get to here if we are logged and we have a
+ # problem
+ unless ( $c->user && $c->stash->{problem} ) {
+ $c->detach('missing_problem');
+ }
+
+ my $problem = $c->cobrand->problems->find( { id => $c->stash->{problem} } );
+
+ # you should not be able to answer questionnaires about problems
+ # that you've not submitted
+ if ( $c->user->id != $problem->user->id ) {
+ $c->detach('missing_problem');
+ }
+
+ push @errors, _('Please say whether you\'ve ever reported a problem to your council before') unless $c->stash->{reported};
+
+ $c->stash->{problem_id} = $c->stash->{problem};
+ $c->stash->{errors} = \@errors;
+ $c->detach( 'creator_fixed' ) if scalar @errors;
+
+ my $questionnaire = $c->model( 'DB::Questionnaire' )->find_or_new(
+ {
+ problem_id => $c->stash->{problem},
+ old_state => 'confirmed',
+ new_state => 'fixed',
+ }
+ );
+
+ unless ( $questionnaire->in_storage ) {
+ $questionnaire->ever_reported( $c->stash->{reported} eq 'Yes' ? 1 : 0 );
+ $questionnaire->whensent( \'ms_current_timestamp()' );
+ $questionnaire->whenanswered( \'ms_current_timestamp()' );
+ $questionnaire->insert;
+ }
+
+ $c->stash->{creator_fixed} = 1;
+ $c->stash->{template} = 'tokens/confirm_update.html';
+
+ return 1;
+}
+
+sub submit_standard : Private {
+ my ( $self, $c ) = @_;
+
+ $c->forward( '/tokens/load_questionnaire', [ $c->req->params->{token} ] );
+ $c->forward( 'check_questionnaire' );
+ $c->forward( 'process_questionnaire' );
+
+ my $problem = $c->stash->{problem};
+ my $old_state = $problem->state;
+ my $new_state = '';
+ $new_state = 'fixed' if $c->stash->{been_fixed} eq 'Yes' && $old_state eq 'confirmed';
+ $new_state = 'confirmed' if $c->stash->{been_fixed} eq 'No' && $old_state eq 'fixed';
+
+ # Record state change, if there was one
+ if ( $new_state ) {
+ $problem->state( $new_state );
+ $problem->lastupdate( \'ms_current_timestamp()' );
+ }
+
+ # If it's not fixed and they say it's still not been fixed, record time update
+ if ( $c->stash->{been_fixed} eq 'No' && $old_state eq 'confirmed' ) {
+ $problem->lastupdate( \'ms_current_timestamp()' );
+ }
+
+ # Record questionnaire response
+ my $reported = undef;
+ $reported = 1 if $c->stash->{reported} eq 'Yes';
+ $reported = 0 if $c->stash->{reported} eq 'No';
+
+ my $q = $c->stash->{questionnaire};
+ $q->update( {
+ whenanswered => \'ms_current_timestamp()',
+ ever_reported => $reported,
+ old_state => $old_state,
+ new_state => $c->stash->{been_fixed} eq 'Unknown' ? 'unknown' : ($new_state || $old_state),
+ } );
+
+ # Record an update if they've given one, or if there's a state change
+ if ( $new_state || $c->stash->{update} ) {
+ my $update = $c->stash->{update} || _('Questionnaire filled in by problem reporter');
+ $update = $c->model('DB::Comment')->new(
+ {
+ problem => $problem,
+ name => $problem->name,
+ user => $problem->user,
+ text => $update,
+ state => 'confirmed',
+ mark_fixed => $new_state eq 'fixed' ? 1 : 0,
+ mark_open => $new_state eq 'confirmed' ? 1 : 0,
+ lang => $c->stash->{lang_code},
+ cobrand => $c->cobrand->moniker,
+ cobrand_data => $c->cobrand->extra_update_data,
+ confirmed => \'ms_current_timestamp()',
+ anonymous => $problem->anonymous,
+ }
+ );
+ if ( my $fileid = $c->stash->{upload_fileid} ) {
+ my $file = file( $c->config->{UPLOAD_CACHE}, "$fileid.jpg" );
+ my $blob = $file->slurp;
+ $file->remove;
+ $update->photo($blob);
+ }
+ $update->insert;
+ }
+
+ # If they've said they want another questionnaire, mark as such
+ $problem->send_questionnaire( 1 )
+ if ($c->stash->{been_fixed} eq 'No' || $c->stash->{been_fixed} eq 'Unknown') && $c->stash->{another} eq 'Yes';
+ $problem->update;
+
+ $c->stash->{new_state} = $new_state;
+ $c->stash->{template} = 'questionnaire/completed.html';
+}
+
+sub process_questionnaire : Private {
+ my ( $self, $c ) = @_;
+
+ map { $c->stash->{$_} = $c->req->params->{$_} || '' } qw(been_fixed reported another update);
+
+ # EHA questionnaires done for you
+ if ($c->cobrand->moniker eq 'emptyhomes') {
+ $c->stash->{another} = $c->stash->{num_questionnaire}==1 ? 'Yes' : 'No';
+ }
+
+ my @errors;
+ push @errors, _('Please state whether or not the problem has been fixed')
+ unless $c->stash->{been_fixed};
+
+ if ($c->cobrand->ask_ever_reported) {
+ push @errors, _('Please say whether you\'ve ever reported a problem to your council before')
+ unless $c->stash->{reported} || $c->stash->{answered_ever_reported};
+ }
+
+ push @errors, _('Please indicate whether you\'d like to receive another questionnaire')
+ if ($c->stash->{been_fixed} eq 'No' || $c->stash->{been_fixed} eq 'Unknown') && !$c->stash->{another};
+
+ push @errors, _('Please provide some explanation as to why you\'re reopening this report')
+ if $c->stash->{been_fixed} eq 'No' && $c->stash->{problem}->state eq 'fixed' && !$c->stash->{update};
+
+ $c->forward('/report/new/process_photo');
+ push @errors, $c->stash->{photo_error}
+ if $c->stash->{photo_error};
+
+ push @errors, _('Please provide some text as well as a photo')
+ if $c->stash->{upload_fileid} && !$c->stash->{update};
+
+ if (@errors) {
+ $c->stash->{errors} = [ @errors ];
+ $c->detach( 'display' );
+ }
+}
+
+# Sent here from email token action. Simply load and display questionnaire.
+sub index : Private {
+ my ( $self, $c ) = @_;
+ $c->forward( 'check_questionnaire' );
+ $c->forward( 'display' );
+}
+
+=head2 display
+
+Displays a questionnaire, either after bad submission or directly from email token.
+
+=cut
+
+sub display : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{template} = 'questionnaire/index.html';
+
+ my $problem = $c->stash->{questionnaire}->problem;
+
+ ( $c->stash->{short_latitude}, $c->stash->{short_longitude} ) =
+ map { Utils::truncate_coordinate($_) }
+ ( $problem->latitude, $problem->longitude );
+
+ $c->stash->{updates} = $c->model('DB::Comment')->search(
+ { problem_id => $problem->id, state => 'confirmed' },
+ { order_by => 'confirmed' }
+ );
+
+ $c->stash->{page} = 'questionnaire';
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ pins => [ {
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ colour => $problem->state eq 'fixed' ? 'green' : 'red',
+ } ],
+ );
+}
+
+=head2 creator_fixed
+
+Display the reduced questionnaire that we display when the reporter of a
+problem submits an update marking it as fixed.
+
+=cut
+
+sub creator_fixed : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{template} = 'questionnaire/creator_fixed.html';
+
+ return 1;
+}
+
+=head1 AUTHOR
+
+Matthew Somerville
+
+=head1 LICENSE
+
+Copyright (c) 2011 UK Citizens Online Democracy. All rights reserved.
+Licensed under the Affero GPL.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm
new file mode 100644
index 000000000..6596615c6
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Report.pm
@@ -0,0 +1,144 @@
+package FixMyStreet::App::Controller::Report;
+
+use Moose;
+use namespace::autoclean;
+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 C<id> parameter in query, in which case redirect to
+'/report/$id'.
+
+=cut
+
+sub index : Path('') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->req->param('id');
+
+ my $uri =
+ $id
+ ? $c->uri_for( '/report', $id )
+ : $c->uri_for('/');
+
+ $c->res->redirect($uri);
+}
+
+=head2 report_display
+
+Display a report.
+
+=cut
+
+sub display : Path('') : Args(1) {
+ my ( $self, $c, $id ) = @_;
+
+ if (
+ $id =~ m{ ^ 3D (\d+) $ }x # Some council with bad email software
+ || $id =~ m{ ^(\d+) \D .* $ }x # trailing garbage
+ )
+ {
+ return $c->res->redirect( $c->uri_for($1), 301 );
+ }
+
+ $c->forward('load_problem_or_display_error', [ $id ] );
+ $c->forward( 'load_updates' );
+ $c->forward( 'format_problem_for_display' );
+}
+
+sub load_problem_or_display_error : Private {
+ my ( $self, $c, $id ) = @_;
+
+ # 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 } );
+
+ # check that the problem is suitable to show.
+ if ( !$problem || $problem->state eq 'unconfirmed' || $problem->state eq 'partial' ) {
+ $c->detach( '/page_error_404_not_found', [ _('Unknown problem ID') ] );
+ }
+ elsif ( $problem->state eq 'hidden' ) {
+ $c->detach(
+ '/page_error_410_gone',
+ [ _('That report has been removed from FixMyStreet.') ] #
+ );
+ }
+
+ $c->stash->{problem} = $problem;
+ return 1;
+}
+
+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' }
+ );
+
+ $c->stash->{updates} = $updates;
+
+ return 1;
+}
+
+sub format_problem_for_display : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem};
+
+ $c->stash->{banner} = $c->cobrand->generate_problem_banner($problem);
+
+ $c->stash->{cobrand_alert_fields} = $c->cobrand->form_elements('/alerts');
+ $c->stash->{cobrand_update_fields} =
+ $c->cobrand->form_elements('/updateForm');
+
+ ( $c->stash->{short_latitude}, $c->stash->{short_longitude} ) =
+ map { Utils::truncate_coordinate($_) }
+ ( $problem->latitude, $problem->longitude );
+
+ unless ( $c->req->param('submit_update') ) {
+ $c->stash->{add_alert} = 1;
+ }
+
+ $c->forward('generate_map_tags');
+
+ 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
+ ? [ {
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ colour => 'blue',
+ } ]
+ : [],
+ );
+
+ return 1;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm
new file mode 100644
index 000000000..346dfb377
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Report/New.pm
@@ -0,0 +1,1047 @@
+package FixMyStreet::App::Controller::Report::New;
+
+use Moose;
+use namespace::autoclean;
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::Geocode;
+use Encode;
+use Image::Magick;
+use List::MoreUtils qw(uniq);
+use POSIX 'strcoll';
+use HTML::Entities;
+use mySociety::MaPit;
+use Path::Class;
+use Utils;
+use mySociety::EmailUtil;
+use mySociety::TempFiles;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Report::New
+
+=head1 DESCRIPTION
+
+Create a new report, or complete a partial one .
+
+=head1 PARAMETERS
+
+=head2 flow control
+
+submit_problem: true if a problem has been submitted, at all.
+submit_sign_in: true if the sign in button has been clicked by logged out user.
+submit_register: true if the register/confirm by email button has been clicked
+by logged out user.
+
+=head2 location (required)
+
+We require a location - either lat/lng or a tile click.
+
+longitude, latitude: location of the report - either determined from the
+address/postcode or from a map click.
+
+x, y, tile_xxx.yyy.x, tile_xxx.yyy.y: x and y are the tile locations. The
+'tile_xxx.yyy' pair are the click locations on the tile. These can be converted
+back into lat/lng by the map code.
+
+=head2 image related
+
+Parameters are 'photo' or 'upload_fileid'. The 'photo' is used when a user has selected a file. Once it has been uploaded it is cached on disk so that if there are errors on the form it need not be uploaded again. The cache location is stored in 'upload_fileid'.
+
+=head2 optional
+
+pc: location user searched for
+
+skipped: true if the map was skipped - may mean that the location is not as
+accurate as we'd like. Default is false.
+
+upload_fileid: set if there is an uploaded file (might not be needed if we use the Catalyst upload handlers)
+
+may_show_name: bool - false if the user wants this report to be anonymous.
+
+title
+
+detail
+
+name
+
+email
+
+phone
+
+partial
+
+=cut
+
+sub report_new : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ # create the report - loading a partial if available
+ $c->forward('initialize_report');
+
+ # work out the location for this report and do some checks
+ return $c->forward('redirect_to_around')
+ unless $c->forward('determine_location');
+
+ # create a problem from the submitted details
+ $c->stash->{template} = "report/new/fill_in_details.html";
+ $c->forward('setup_categories_and_councils');
+ $c->forward('generate_map');
+
+ # deal with the user and report and check both are happy
+ return unless $c->forward('check_form_submitted');
+ $c->forward('process_user');
+ $c->forward('process_report');
+ $c->forward('process_photo');
+ return unless $c->forward('check_for_errors');
+ $c->forward('save_user_and_report');
+ $c->forward('redirect_or_confirm_creation');
+}
+
+=head2 report_import
+
+Action to accept report creations from iPhones and other mobile apps. URL is
+'/import' to be compatible with existing apps.
+
+=cut
+
+sub report_import : Path('/import') {
+ my ( $self, $c ) = @_;
+
+ # 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 %input =
+ map { $_ => $c->req->param($_) || '' } (
+ 'service', 'subject', 'detail', 'name', 'email', 'phone',
+ 'easting', 'northing', 'lat', 'lon', 'id', 'phone_id',
+ );
+
+ my @errors;
+
+ # Get our location
+ my $latitude = $input{lat} ||= 0;
+ my $longitude = $input{lon} ||= 0;
+ if (
+ !( $latitude || $longitude ) # have not been given lat or lon
+ && ( $input{easting} && $input{northing} ) # but do have e and n
+ )
+ {
+ ( $latitude, $longitude ) =
+ Utils::convert_en_to_latlon( $input{easting}, $input{northing} );
+ }
+
+ # handle the photo upload
+ $c->forward( 'process_photo_upload', [ { rotate_photo => 1 } ] );
+ my $photo = $c->stash->{upload_fileid};
+ if ( my $error = $c->stash->{photo_error} ) {
+ push @errors, $error;
+ }
+
+ push @errors, 'You must supply a service' unless $input{service};
+ push @errors, 'Please enter a subject' unless $input{subject} =~ /\S/;
+ push @errors, 'Please enter your name' unless $input{name} =~ /\S/;
+
+ if ( $input{email} !~ /\S/ ) {
+ push @errors, 'Please enter your email';
+ }
+ elsif ( !mySociety::EmailUtil::is_valid_email( $input{email} ) ) {
+ push @errors, 'Please enter a valid email';
+ }
+
+ if ( $latitude && $c->cobrand->country eq 'GB' ) {
+ eval { Utils::convert_latlon_to_en( $latitude, $longitude ); };
+ push @errors,
+ "We had a problem with the supplied co-ordinates - outside the UK?"
+ if $@;
+ }
+
+ unless ( $photo || ( $latitude || $longitude ) ) {
+ push @errors, 'Either a location or a photo must be provided.';
+ }
+
+ # if we have errors then we should bail out
+ if (@errors) {
+ my $body = join '', map { "ERROR:$_\n" } @errors;
+ $c->res->body($body);
+ return;
+ }
+
+### leaving commented out for now as the values stored here never appear to
+### get used and the new user accounts might make them redundant anyway.
+ #
+ # # Store for possible future use
+ # if ( $input{id} || $input{phone_id} ) {
+ # my $id = $input{id} || $input{phone_id};
+ # my $already =
+ # dbh()
+ # ->selectrow_array(
+ # 'select id from partial_user where service=? and nsid=?',
+ # {}, $input{service}, $id );
+ # unless ($already) {
+ # dbh()->do(
+ # 'insert into partial_user (service, nsid, name, email, phone)'
+ # . ' values (?, ?, ?, ?, ?)',
+ # {},
+ # $input{service},
+ # $id,
+ # $input{name},
+ # $input{email},
+ # $input{phone}
+ # );
+ # }
+ # }
+
+ # find or create the user
+ my $report_user = $c->model('DB::User')->find_or_create(
+ {
+ email => $input{email},
+ name => $input{name},
+ phone => $input{phone}
+ }
+ );
+
+ # create a new report (don't save it yet)
+ my $report = $c->model('DB::Problem')->new(
+ {
+ user => $report_user,
+ postcode => '',
+ latitude => $latitude,
+ longitude => $longitude,
+ title => $input{subject},
+ detail => $input{detail},
+ name => $input{name},
+ service => $input{service},
+ state => 'partial',
+ used_map => 1,
+ anonymous => 0,
+ category => '',
+ areas => '',
+ cobrand => $c->cobrand->moniker,
+ lang => $c->stash->{lang_code},
+
+ }
+ );
+
+ # If there was a photo add that too
+ if ( $photo ) {
+ my $file = file( $c->config->{UPLOAD_CACHE}, "$photo.jpg" );
+ my $blob = $file->slurp;
+ $file->remove;
+ $report->photo($blob);
+ }
+
+ # save the report;
+ $report->insert();
+
+ my $token =
+ $c->model("DB::Token")
+ ->create( { scope => 'partial', data => $report->id } );
+
+ $c->stash->{report} = $report;
+ $c->stash->{token_url} = $c->uri_for_email( '/L', $token->token );
+
+ $c->send_email( 'partial.txt', { to => $report->user->email, } );
+
+ $c->res->body('SUCCESS');
+ return 1;
+}
+
+=head2 initialize_report
+
+Create the report and set up some basics in it. If there is a partial report
+requested then use that .
+
+Partial reports are created when people submit to us e.g. via mobile apps.
+They are in the database but are not completed yet. Users reach us by following
+a link we email them that contains a token link. This action looks for the
+token and if found retrieves the report in it.
+
+=cut
+
+sub initialize_report : Private {
+ my ( $self, $c ) = @_;
+
+ # check to see if there is a partial report that we should use, otherwise
+ # create a new one. Stick it on the stash.
+ my $report = undef;
+
+ if ( my $partial = scalar $c->req->param('partial') ) {
+
+ for (1) { # use as pseudo flow control
+
+ # did we find a token
+ last unless $partial;
+
+ # is it in the database
+ my $token =
+ $c->model("DB::Token")
+ ->find( { scope => 'partial', token => $partial } ) #
+ || last;
+
+ # can we get an id from it?
+ my $id = $token->data #
+ || last;
+
+ # load the related problem
+ $report = $c->cobrand->problems #
+ ->search( { id => $id, state => 'partial' } ) #
+ ->first;
+
+ if ($report) {
+
+ # log the problem creation user in to the site
+ $c->authenticate( { email => $report->user->email },
+ 'no_password' );
+
+ # save the token to delete at the end
+ $c->stash->{partial_token} = $token if $report;
+
+ }
+ else {
+
+ # no point keeping it if it is done.
+ $token->delete;
+ }
+ }
+ }
+
+ if ( !$report ) {
+
+ # If we didn't find a partial then create a new one
+ $report = $c->model('DB::Problem')->new( {} );
+
+ # If we have a user logged in let's prefill some values for them.
+ if ( $c->user ) {
+ my $user = $c->user->obj;
+ $report->user($user);
+ $report->name( $user->name );
+ }
+
+ }
+
+ # Capture whether the map was used
+ $report->used_map( $c->req->param('skipped') ? 0 : 1 );
+
+ $c->stash->{report} = $report;
+
+ return 1;
+}
+
+=head2 determine_location
+
+Work out what the location of the report should be - either by using lat,lng or
+a tile click or what's come in from a partial. Returns false if no location
+could be found.
+
+=cut
+
+sub determine_location : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{fetch_all_areas} = 1;
+ return 1
+ if #
+ ( #
+ $c->forward('determine_location_from_tile_click')
+ || $c->forward('/location/determine_location_from_coords')
+ || $c->forward('determine_location_from_report')
+ ) #
+ && $c->forward('/around/check_location_is_acceptable');
+ return;
+}
+
+=head2 determine_location_from_tile_click
+
+Detect that the map tiles have been clicked on by looking for the tile
+parameters.
+
+=cut
+
+sub determine_location_from_tile_click : Private {
+ my ( $self, $c ) = @_;
+
+ # example: 'tile_1673.1451.x'
+ my $param_key_regex = '^tile_(\d+)\.(\d+)\.[xy]$';
+
+ my @matching_param_keys =
+ grep { m/$param_key_regex/ } keys %{ $c->req->params };
+
+ # did we find any matches
+ return unless scalar(@matching_param_keys) == 2;
+
+ # get the x and y keys
+ my ( $x_key, $y_key ) = sort @matching_param_keys;
+
+ # Extract the data needed
+ my ( $pin_tile_x, $pin_tile_y ) = $x_key =~ m{$param_key_regex};
+ my $pin_x = $c->req->param($x_key);
+ my $pin_y = $c->req->param($y_key);
+
+ # return if they are both 0 - this happens when you submit the form by
+ # hitting enter and not using the button. It also happens if you click
+ # exactly there on the map but that is less likely than hitting return to
+ # submit. Lesser of two evils...
+ return unless $pin_x && $pin_y;
+
+ # convert the click to lat and lng
+ my ( $latitude, $longitude ) = FixMyStreet::Map::click_to_wgs84(
+ $c,
+ $pin_tile_x, $pin_x, $pin_tile_y, $pin_y
+ );
+
+ # store it on the stash
+ $c->stash->{latitude} = $latitude;
+ $c->stash->{longitude} = $longitude;
+
+ # set a flag so that the form is not considered submitted. This will prevent
+ # errors showing on the fields.
+ $c->stash->{force_form_not_submitted} = 1;
+
+ # return true as we found a location
+ return 1;
+}
+
+=head2 determine_location_from_report
+
+Use latitude and longitude stored in the report - this is probably result of a
+partial report being loaded.
+
+=cut
+
+sub determine_location_from_report : Private {
+ my ( $self, $c ) = @_;
+
+ my $report = $c->stash->{report};
+
+ if ( defined $report->latitude && defined $report->longitude ) {
+ $c->stash->{latitude} = $report->latitude;
+ $c->stash->{longitude} = $report->longitude;
+ return 1;
+ }
+
+ return;
+}
+
+=head2 setup_categories_and_councils
+
+Look up categories for this council or councils
+
+=cut
+
+sub setup_categories_and_councils : Private {
+ my ( $self, $c ) = @_;
+
+ my $all_councils = $c->stash->{all_councils};
+ my $first_council = ( values %$all_councils )[0];
+
+ my @contacts #
+ = $c #
+ ->model('DB::Contact') #
+ ->not_deleted #
+ ->search( { area_id => [ keys %$all_councils ] } ) #
+ ->all;
+
+ # variables to populate
+ my %area_ids_to_list = (); # Areas with categories assigned
+ my @category_options = (); # categories to show
+ my $category_label = undef; # what to call them
+
+ # FIXME - implement in cobrand
+ if ( $c->cobrand->moniker eq 'emptyhomes' ) {
+
+ # add all areas found to the list
+ foreach (@contacts) {
+ $area_ids_to_list{ $_->area_id } = 1;
+ }
+
+ # set our own categories
+ @category_options = (
+ _('-- Pick a property type --'),
+ _('Empty house or bungalow'),
+ _('Empty flat or maisonette'),
+ _('Whole block of empty flats'),
+ _('Empty office or other commercial'),
+ _('Empty pub or bar'),
+ _('Empty public building - school, hospital, etc.')
+ );
+ $category_label = _('Property type:');
+
+ } elsif ($first_council->{type} eq 'LBO') {
+
+ $area_ids_to_list{ $first_council->{id} } = 1;
+ @category_options = (
+ _('-- Pick a category --'),
+ sort keys %{ Utils::london_categories() }
+ );
+ $category_label = _('Category:');
+
+ } else {
+
+ # keysort does not appear to obey locale so use strcoll (see i18n.t)
+ @contacts = sort { strcoll( $a->category, $b->category ) } @contacts;
+
+ my %seen;
+ foreach my $contact (@contacts) {
+
+ $area_ids_to_list{ $contact->area_id } = 1;
+
+ next # TODO - move this to the cobrand
+ if $c->cobrand->moniker eq 'southampton'
+ && $contact->category =~ /Street lighting|Traffic lights/;
+
+ next if $contact->category eq _('Other');
+
+ push @category_options, $contact->category
+ unless $seen{$contact->category};
+ $seen{$contact->category} = 1;
+ }
+
+ if (@category_options) {
+ @category_options =
+ ( _('-- Pick a category --'), @category_options, _('Other') );
+ $category_label = _('Category:');
+ }
+ }
+
+ # put results onto stash for display
+ $c->stash->{area_ids_to_list} = [ keys %area_ids_to_list ];
+ $c->stash->{category_label} = $category_label;
+ $c->stash->{category_options} = \@category_options;
+
+ my @missing_details_councils =
+ grep { !$area_ids_to_list{$_} } #
+ keys %$all_councils;
+
+ my @missing_details_council_names =
+ map { $all_councils->{$_}->{name} } #
+ @missing_details_councils;
+
+ $c->stash->{missing_details_councils} = \@missing_details_councils;
+ $c->stash->{missing_details_council_names} = \@missing_details_council_names;
+}
+
+=head2 check_form_submitted
+
+ $bool = $c->forward('check_form_submitted');
+
+Returns true if the form has been submitted, false if not. Determines this based
+on the presence of the C<submit_problem> parameter.
+
+=cut
+
+sub check_form_submitted : Private {
+ my ( $self, $c ) = @_;
+ return if $c->stash->{force_form_not_submitted};
+ return $c->req->param('submit_problem') || '';
+}
+
+=head2 process_user
+
+Load user from the database or prepare a new one.
+
+=cut
+
+sub process_user : Private {
+ my ( $self, $c ) = @_;
+
+ my $report = $c->stash->{report};
+
+ # The user is already signed in
+ if ( $c->user_exists ) {
+ my $user = $c->user->obj;
+ my %params = map { $_ => scalar $c->req->param($_) } ( 'name', 'phone' );
+ $user->name( Utils::trim_text( $params{name} ) ) if $params{name};
+ $user->phone( Utils::trim_text( $params{phone} ) );
+ $report->user( $user );
+ $report->name( $user->name );
+ return 1;
+ }
+
+ # Extract all the params to a hash to make them easier to work with
+ my %params = map { $_ => scalar $c->req->param($_) }
+ ( 'email', 'name', 'phone', 'password_register' );
+
+ # cleanup the email address
+ my $email = $params{email} ? lc $params{email} : '';
+ $email =~ s{\s+}{}g;
+
+ $report->user( $c->model('DB::User')->find_or_new( { email => $email } ) )
+ unless $report->user;
+
+ # The user is trying to sign in. We only care about email from the params.
+ if ( $c->req->param('submit_sign_in') || $c->req->param('password_sign_in') ) {
+ unless ( $c->forward( '/auth/sign_in' ) ) {
+ $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. Passwords and user accounts are a brand <strong>new</strong> service, so you probably do not have one yet &ndash; please fill in the right hand side of this form to get one.');
+ return 1;
+ }
+ my $user = $c->user->obj;
+ $report->user( $user );
+ $report->name( $user->name );
+ $c->stash->{field_errors}->{name} = _('You have successfully signed in; please check and confirm your details are accurate:');
+ return 1;
+ }
+
+ # set the user's name, phone, and password
+ $report->user->name( Utils::trim_text( $params{name} ) ) if $params{name};
+ $report->user->phone( Utils::trim_text( $params{phone} ) );
+ $report->user->password( Utils::trim_text( $params{password_register} ) );
+ $report->name( Utils::trim_text( $params{name} ) );
+
+ return 1;
+}
+
+=head2 process_report
+
+Looking at the parameters passed in create a new item and return it. Does not
+save anything to the database. If no item can be created (ie no information
+provided) returns undef.
+
+=cut
+
+sub process_report : Private {
+ my ( $self, $c ) = @_;
+
+ # Extract all the params to a hash to make them easier to work with
+ my %params = #
+ map { $_ => scalar $c->req->param($_) } #
+ (
+ 'title', 'detail', 'pc', #
+ 'detail_size', 'detail_depth',
+ 'may_show_name', #
+ 'category', #
+ 'partial', #
+ );
+
+ # load the report
+ my $report = $c->stash->{report};
+
+ # Enter the location and other bits which are not from the form
+ $report->postcode( $params{pc} );
+ $report->latitude( $c->stash->{latitude} );
+ $report->longitude( $c->stash->{longitude} );
+
+ # set some simple bool values (note they get inverted)
+ $report->anonymous( $params{may_show_name} ? 0 : 1 );
+
+ # clean up text before setting
+ $report->title( Utils::cleanup_text( $params{title} ) );
+
+ my $detail = Utils::cleanup_text( $params{detail}, { allow_multiline => 1 } );
+ for my $w ('depth', 'size') {
+ next unless $params{"detail_$w"};
+ next if $params{"detail_$w"} eq '-- Please select --';
+ $detail .= "\n\n\u$w: " . $params{"detail_$w"};
+ }
+ $report->detail( $detail );
+
+ # set these straight from the params
+ $report->category( _ $params{category} );
+
+ my $areas = $c->stash->{all_areas};
+ $report->areas( ',' . join( ',', sort keys %$areas ) . ',' );
+
+ # From earlier in the process.
+ my $councils = $c->stash->{all_councils};
+ my $first_council = ( values %$councils )[0];
+
+ if ( $c->cobrand->moniker eq 'emptyhomes' ) {
+
+ $councils = join( ',', @{ $c->stash->{area_ids_to_list} } ) || -1;
+ $report->council( $councils );
+
+ } elsif ( $first_council->{type} eq 'LBO') {
+
+ unless ( Utils::london_categories()->{ $report->category } ) {
+ $c->stash->{field_errors}->{category} = _('Please choose a category');
+ }
+ $report->council( $first_council->{id} );
+
+ } elsif ( $report->category ) {
+
+ # FIXME All contacts were fetched in setup_categories_and_councils,
+ # so can this DB call also be avoided?
+ my @contacts = $c-> #
+ model('DB::Contact') #
+ ->not_deleted #
+ ->search(
+ {
+ area_id => [ keys %$councils ],
+ category => $report->category
+ }
+ )->all;
+
+ unless ( @contacts ) {
+ $c->stash->{field_errors}->{category} = _('Please choose a category');
+ $report->council( -1 );
+ return 1;
+ }
+
+ # construct the council string:
+ # 'x,x' - x are council IDs that have this category
+ # 'x,x|y,y' - x are council IDs that have this category, y council IDs with *no* contact
+ my $council_string = join( ',', map { $_->area_id } @contacts );
+ $council_string .=
+ '|' . join( ',', @{ $c->stash->{missing_details_councils} } )
+ if $council_string && @{ $c->stash->{missing_details_councils} };
+ $report->council($council_string);
+
+ } elsif ( @{ $c->stash->{area_ids_to_list} } ) {
+
+ # There was an area with categories, but we've not been given one. Bail.
+ $c->stash->{field_errors}->{category} = _('Please choose a category');
+
+ } else {
+
+ # If we're here, we've been submitted somewhere
+ # where we have no contact information at all.
+ $report->council( -1 );
+
+ }
+
+ # set defaults that make sense
+ $report->state('unconfirmed');
+
+ # save the cobrand and language related information
+ $report->cobrand( $c->cobrand->moniker );
+ $report->cobrand_data( $c->cobrand->extra_problem_data );
+ $report->lang( $c->stash->{lang_code} );
+
+ return 1;
+}
+
+=head2 process_photo
+
+Handle the photo - either checking and storing it after an upload or retrieving
+it from the cache.
+
+Store any error message onto 'photo_error' in stash.
+=cut
+
+sub process_photo : Private {
+ my ( $self, $c ) = @_;
+
+ return
+ $c->forward('process_photo_upload')
+ || $c->forward('process_photo_cache')
+ || 1; # always return true
+}
+
+sub process_photo_upload : Private {
+ my ( $self, $c, $args ) = @_;
+
+ # setup args and set defaults
+ $args ||= {};
+ $args->{rotate_photo} ||= 0;
+
+ # check for upload or return
+ my $upload = $c->req->upload('photo')
+ || return;
+
+ # check that the photo is a jpeg
+ my $ct = $upload->type;
+ unless ( $ct eq 'image/jpeg' || $ct eq 'image/pjpeg' ) {
+ $c->stash->{photo_error} = _('Please upload a JPEG image only');
+ return;
+ }
+
+ # convert the photo into a blob (also resize etc)
+ my $photo_blob =
+ eval { _process_photo( $upload->fh, $args->{rotate_photo} ) };
+ if ( my $error = $@ ) {
+ my $format = _(
+"That image doesn't appear to have uploaded correctly (%s), please try again."
+ );
+ $c->stash->{photo_error} = sprintf( $format, $error );
+ return;
+ }
+
+ # we have an image we can use - save it to the cache in case there is an
+ # error
+ my $cache_dir = dir( $c->config->{UPLOAD_CACHE} );
+ $cache_dir->mkpath;
+ unless ( -d $cache_dir && -w $cache_dir ) {
+ warn "Can't find/write to photo cache directory '$cache_dir'";
+ return;
+ }
+
+ # create a random name and store the file there
+ my $fileid = int rand 1_000_000_000;
+ my $file = $cache_dir->file("$fileid.jpg");
+ $file->openw->print($photo_blob);
+
+ # stick the random number on the stash
+ $c->stash->{upload_fileid} = $fileid;
+
+ return 1;
+}
+
+=head2 process_photo_cache
+
+Look for the upload_fileid parameter and check it matches a file on disk. If it
+does return true and put fileid on stash, otherwise false.
+
+=cut
+
+sub process_photo_cache : Private {
+ my ( $self, $c ) = @_;
+
+ # get the fileid and make sure it is just a number
+ my $fileid = $c->req->param('upload_fileid') || '';
+ $fileid =~ s{\D+}{}g;
+ return unless $fileid;
+
+ my $file = file( $c->config->{UPLOAD_CACHE}, "$fileid.jpg" );
+ return unless -e $file;
+
+ $c->stash->{upload_fileid} = $fileid;
+ return 1;
+}
+
+=head2 check_for_errors
+
+Examine the user and the report for errors. If found put them on stash and
+return false.
+
+=cut
+
+sub check_for_errors : Private {
+ my ( $self, $c ) = @_;
+
+ # let the model check for errors
+ $c->stash->{field_errors} ||= {};
+ my %field_errors = (
+ %{ $c->stash->{field_errors} },
+ %{ $c->stash->{report}->user->check_for_errors },
+ %{ $c->stash->{report}->check_for_errors },
+ );
+
+ # add the photo error if there is one.
+ if ( my $photo_error = delete $c->stash->{photo_error} ) {
+ $field_errors{photo} = $photo_error;
+ }
+
+ # all good if no errors
+ return 1 unless scalar keys %field_errors;
+
+ $c->stash->{field_errors} = \%field_errors;
+
+ return;
+}
+
+=head2 save_user_and_report
+
+Save the user and the report.
+
+Be smart about the user - only set the name and phone if user did not exist
+before or they are currently logged in. Otherwise discard any changes.
+
+=cut
+
+sub save_user_and_report : Private {
+ my ( $self, $c ) = @_;
+ my $report = $c->stash->{report};
+
+ # Save or update the user if appropriate
+ if ( !$report->user->in_storage ) {
+ $report->user->insert();
+ }
+ elsif ( $c->user && $report->user->id == $c->user->id ) {
+ $report->user->update();
+ $report->confirm;
+ }
+ else {
+ # User exists and we are not logged in as them.
+ # Store changes in token for when token is validated.
+ $c->stash->{token_data} = {
+ name => $report->user->name,
+ phone => $report->user->phone,
+ password => $report->user->password,
+ };
+ $report->user->discard_changes();
+ }
+
+ # If there was a photo add that too
+ if ( my $fileid = $c->stash->{upload_fileid} ) {
+ my $file = file( $c->config->{UPLOAD_CACHE}, "$fileid.jpg" );
+ my $blob = $file->slurp;
+ $file->remove;
+ $report->photo($blob);
+ }
+
+ # Set a default if possible
+ $report->category( _('Other') ) unless $report->category;
+
+ # Set unknown to DB unknown
+ $report->council( undef ) if $report->council eq '-1';
+
+ # save the report;
+ $report->in_storage ? $report->update : $report->insert();
+
+ # tidy up
+ if ( my $token = $c->stash->{partial_token} ) {
+ $token->delete;
+ }
+
+ return 1;
+}
+
+=head2 generate_map
+
+Add the html needed to for the map to the stash.
+
+=cut
+
+# Perhaps also create a map 'None' to use when map is skipped.
+
+sub generate_map : Private {
+ my ( $self, $c ) = @_;
+ my $latitude = $c->stash->{latitude};
+ my $longitude = $c->stash->{longitude};
+
+ ( $c->stash->{short_latitude}, $c->stash->{short_longitude} ) =
+ map { Utils::truncate_coordinate($_) }
+ ( $c->stash->{latitude}, $c->stash->{longitude} );
+
+ # Don't do anything if the user skipped the map
+ unless ( $c->req->param('skipped') ) {
+ $c->stash->{page} = 'new';
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => $latitude,
+ longitude => $longitude,
+ clickable => 1,
+ pins => [ {
+ latitude => $latitude,
+ longitude => $longitude,
+ colour => 'purple',
+ } ],
+ );
+ }
+
+ return 1;
+}
+
+=head2 redirect_or_confirm_creation
+
+Now that the report has been created either redirect the user to its page if it
+has been confirmed or email them a token if it has not been.
+
+=cut
+
+sub redirect_or_confirm_creation : Private {
+ my ( $self, $c ) = @_;
+ my $report = $c->stash->{report};
+
+ # If confirmed send the user straight there.
+ if ( $report->confirmed ) {
+ # Subscribe problem reporter to email updates
+ $c->forward( 'create_reporter_alert' );
+ my $report_uri = $c->uri_for( '/report', $report->id );
+ $c->res->redirect($report_uri);
+ $c->detach;
+ }
+
+ # otherwise create a confirm token and email it to them.
+ my $data = $c->stash->{token_data} || {};
+ my $token = $c->model("DB::Token")->create( {
+ scope => 'problem',
+ data => {
+ %$data,
+ id => $report->id
+ }
+ } );
+ $c->stash->{token_url} = $c->uri_for_email( '/P', $token->token );
+ $c->send_email( 'problem-confirm.txt', {
+ to => [ [ $report->user->email, $report->name ] ],
+ } );
+
+ # tell user that they've been sent an email
+ $c->stash->{template} = 'email_sent.html';
+ $c->stash->{email_type} = 'problem';
+}
+
+sub create_reporter_alert : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{report};
+ my $alert = $c->model('DB::Alert')->find_or_create( {
+ user => $problem->user,
+ alert_type => 'new_updates',
+ parameter => $problem->id,
+ cobrand => $problem->cobrand,
+ cobrand_data => $problem->cobrand_data,
+ lang => $problem->lang,
+ } )->confirm;
+}
+
+=head2 redirect_to_around
+
+Redirect the user to '/around' passing along all the relevant parameters.
+
+=cut
+
+sub redirect_to_around : Private {
+ my ( $self, $c ) = @_;
+
+ my $params = {
+ pc => ( $c->stash->{pc} || $c->req->param('pc') || '' ),
+ lat => $c->stash->{latitude},
+ lon => $c->stash->{longitude},
+ };
+
+ # delete empty values
+ for ( keys %$params ) {
+ delete $params->{$_} if !$params->{$_};
+ }
+
+ if ( my $token = $c->stash->{partial_token} ) {
+ $params->{partial} = $token->token;
+ }
+
+ my $around_uri = $c->uri_for( '/around', $params );
+
+ return $c->res->redirect($around_uri);
+}
+
+sub _process_photo {
+ my $fh = shift;
+ my $import = shift;
+
+ my $blob = join('', <$fh>);
+ close $fh;
+ my ($handle, $filename) = mySociety::TempFiles::named_tempfile('.jpeg');
+ print $handle $blob;
+ close $handle;
+
+ my $photo = Image::Magick->new;
+ my $err = $photo->Read($filename);
+ unlink $filename;
+ throw Error::Simple("read failed: $err") if "$err";
+ $err = $photo->Scale(geometry => "250x250>");
+ throw Error::Simple("resize failed: $err") if "$err";
+ my @blobs = $photo->ImageToBlob();
+ undef $photo;
+ $photo = $blobs[0];
+ return $photo unless $import; # Only check orientation for iPhone imports at present
+
+ # Now check if it needs orientating
+ ($fh, $filename) = mySociety::TempFiles::named_tempfile('.jpeg');
+ print $fh $photo;
+ close $fh;
+ my $out = `jhead -se -autorot $filename`;
+ if ($out) {
+ open(FP, $filename) or throw Error::Simple($!);
+ $photo = join('', <FP>);
+ close FP;
+ }
+ unlink $filename;
+ return $photo;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm
new file mode 100644
index 000000000..501dd2b41
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm
@@ -0,0 +1,343 @@
+package FixMyStreet::App::Controller::Report::Update;
+
+use Moose;
+use namespace::autoclean;
+BEGIN { extends 'Catalyst::Controller'; }
+
+use Path::Class;
+use Utils;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Report::Update
+
+=head1 DESCRIPTION
+
+Creates an update to a report
+
+=cut
+
+sub report_update : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->forward( '/report/load_problem_or_display_error', [ $c->req->param('id') ] );
+ $c->forward('process_update');
+ $c->forward('process_user');
+ $c->forward('/report/new/process_photo');
+ $c->forward('check_for_errors')
+ or $c->go( '/report/display', [ $c->req->param('id') ] );
+
+ $c->forward('save_update');
+ $c->forward('redirect_or_confirm_creation');
+}
+
+sub confirm : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{update}->confirm;
+ $c->stash->{update}->update;
+
+ $c->forward('update_problem');
+ $c->forward('signup_for_alerts');
+
+ return 1;
+}
+
+sub update_problem : Private {
+ my ( $self, $c ) = @_;
+
+ my $display_questionnaire = 0;
+
+ my $update = $c->stash->{update};
+ my $problem = $c->stash->{problem} || $update->problem;
+
+ if ( $update->mark_fixed ) {
+ $problem->state('fixed');
+
+ if ( $update->user->id == $problem->user->id ) {
+ $problem->send_questionnaire(0);
+
+ if ( $c->cobrand->ask_ever_reported
+ && !$problem->user->answered_ever_reported )
+ {
+ $display_questionnaire = 1;
+ }
+ }
+ }
+
+ if ( $update->mark_open && $update->user->id == $problem->user->id ) {
+ $problem->state('confirmed');
+ }
+
+ $problem->lastupdate( \'ms_current_timestamp()' );
+ $problem->update;
+
+ $c->stash->{problem_id} = $problem->id;
+
+ if ($display_questionnaire) {
+ $c->detach('/questionnaire/creator_fixed');
+ }
+
+ return 1;
+}
+
+=head2 process_user
+
+Load user from the database or prepare a new one.
+
+=cut
+
+sub process_user : Private {
+ my ( $self, $c ) = @_;
+
+ my $update = $c->stash->{update};
+
+ if ( $c->user_exists ) {
+ my $user = $c->user->obj;
+ my $name = scalar $c->req->param('name');
+ $user->name( Utils::trim_text( $name ) ) if $name;
+ $update->user( $user );
+ return 1;
+ }
+
+ # Extract all the params to a hash to make them easier to work with
+ my %params = map { $_ => scalar $c->req->param($_) }
+ ( 'rznvy', 'name', 'password_register' );
+
+ # cleanup the email address
+ my $email = $params{rznvy} ? lc $params{rznvy} : '';
+ $email =~ s{\s+}{}g;
+
+ $update->user( $c->model('DB::User')->find_or_new( { email => $email } ) )
+ unless $update->user;
+
+ # The user is trying to sign in. We only care about email from the params.
+ if ( $c->req->param('submit_sign_in') || $c->req->param('password_sign_in') ) {
+ unless ( $c->forward( '/auth/sign_in', [ $email ] ) ) {
+ $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. Passwords and user accounts are a brand <strong>new</strong> service, so you probably do not have one yet &ndash; please fill in the right hand side of this form to get one.');
+ return 1;
+ }
+ my $user = $c->user->obj;
+ $update->user( $user );
+ $update->name( $user->name );
+ $c->stash->{field_errors}->{name} = _('You have successfully signed in; please check and confirm your details are accurate:');
+ return 1;
+ }
+
+ $update->user->name( Utils::trim_text( $params{name} ) )
+ if $params{name};
+ $update->user->password( Utils::trim_text( $params{password_register} ) );
+
+ return 1;
+}
+
+=head2 process_update
+
+Take the submitted params and create a new update item. Does not save
+anything to the database.
+
+NB: relies on their being a problem and update_user in the stash. May
+want to move adding these elsewhere
+
+=cut
+
+sub process_update : Private {
+ my ( $self, $c ) = @_;
+
+ my %params =
+ map { $_ => scalar $c->req->param($_) } ( 'update', 'name', 'fixed', 'reopen' );
+
+ $params{update} =
+ Utils::cleanup_text( $params{update}, { allow_multiline => 1 } );
+
+ my $name = Utils::trim_text( $params{name} );
+ my $anonymous = $c->req->param('may_show_name') ? 0 : 1;
+
+ $params{reopen} = 0 unless $c->user && $c->user->id == $c->stash->{problem}->user->id;
+
+ my $update = $c->model('DB::Comment')->new(
+ {
+ text => $params{update},
+ name => $name,
+ problem => $c->stash->{problem},
+ state => 'unconfirmed',
+ mark_fixed => $params{fixed} ? 1 : 0,
+ mark_open => $params{reopen} ? 1 : 0,
+ cobrand => $c->cobrand->moniker,
+ cobrand_data => $c->cobrand->extra_update_data,
+ lang => $c->stash->{lang_code},
+ anonymous => $anonymous,
+ }
+ );
+
+ $c->stash->{update} = $update;
+ $c->stash->{add_alert} = $c->req->param('add_alert');
+
+ return 1;
+}
+
+
+=head2 check_for_errors
+
+Examine the user and the report for errors. If found put them on stash and
+return false.
+
+=cut
+
+sub check_for_errors : Private {
+ my ( $self, $c ) = @_;
+
+ # let the model check for errors
+ $c->stash->{field_errors} ||= {};
+ my %field_errors = (
+ %{ $c->stash->{field_errors} },
+ %{ $c->stash->{update}->user->check_for_errors },
+ %{ $c->stash->{update}->check_for_errors },
+ );
+
+ if ( my $photo_error = delete $c->stash->{photo_error} ) {
+ $field_errors{photo} = $photo_error;
+ }
+
+ # all good if no errors
+ return 1
+ unless ( scalar keys %field_errors
+ || ( $c->stash->{errors} && scalar @{ $c->stash->{errors} } ) );
+
+ $c->stash->{field_errors} = \%field_errors;
+
+ $c->stash->{errors} ||= [];
+ #push @{ $c->stash->{errors} },
+ # _('There were problems with your update. Please see below.');
+
+ return;
+}
+
+=head2 save_update
+
+Save the update and the user as appropriate.
+
+=cut
+
+sub save_update : Private {
+ my ( $self, $c ) = @_;
+
+ my $update = $c->stash->{update};
+
+ if ( !$update->user->in_storage ) {
+ $update->user->insert;
+ }
+ elsif ( $c->user && $c->user->id == $update->user->id ) {
+ # Logged in and same user, so can confirm update straight away
+ $update->user->update;
+ $update->confirm;
+ } else {
+ # User exists and we are not logged in as them.
+ # Store changes in token for when token is validated.
+ $c->stash->{token_data} = {
+ name => $update->user->name,
+ password => $update->user->password,
+ };
+ $update->user->discard_changes();
+ }
+
+ # If there was a photo add that too
+ if ( my $fileid = $c->stash->{upload_fileid} ) {
+ my $file = file( $c->config->{UPLOAD_CACHE}, "$fileid.jpg" );
+ my $blob = $file->slurp;
+ $file->remove;
+ $update->photo($blob);
+ }
+
+ if ( $update->in_storage ) {
+ $update->update;
+ }
+ else {
+ $update->insert;
+ }
+
+ return 1;
+}
+
+=head2 redirect_or_confirm_creation
+
+Now that the update has been created either redirect the user to problem page if it
+has been confirmed or email them a token if it has not been.
+
+=cut
+
+sub redirect_or_confirm_creation : Private {
+ my ( $self, $c ) = @_;
+ my $update = $c->stash->{update};
+
+ # If confirmed send the user straight there.
+ if ( $update->confirmed ) {
+ $c->forward( 'update_problem' );
+ $c->forward( 'signup_for_alerts' );
+ my $report_uri = $c->uri_for( '/report', $update->problem_id );
+ $c->res->redirect($report_uri);
+ $c->detach;
+ }
+
+ # otherwise create a confirm token and email it to them.
+ my $data = $c->stash->{token_data} || {};
+ my $token = $c->model("DB::Token")->create(
+ {
+ scope => 'comment',
+ data => {
+ %$data,
+ id => $update->id,
+ add_alert => ( $c->req->param('add_alert') ? 1 : 0 ),
+ }
+ }
+ );
+ $c->stash->{token_url} = $c->uri_for_email( '/C', $token->token );
+ $c->send_email( 'update-confirm.txt', {
+ to => $update->name
+ ? [ [ $update->user->email, $update->name ] ]
+ : $update->user->email,
+ } );
+
+ # tell user that they've been sent an email
+ $c->stash->{template} = 'email_sent.html';
+ $c->stash->{email_type} = 'update';
+
+ return 1;
+}
+
+=head2 signup_for_alerts
+
+If the user has selected to be signed up for alerts then create a
+new_updates alert. Or if they're logged in and they've unticked the
+box, disable their alert.
+
+NB: this does not check if they are a registered user so that should
+happen before calling this.
+
+=cut
+
+sub signup_for_alerts : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->stash->{add_alert} ) {
+ my $update = $c->stash->{update};
+ my $alert = $c->model('DB::Alert')->find_or_create(
+ user => $update->user,
+ alert_type => 'new_updates',
+ parameter => $update->problem_id,
+ cobrand => $update->cobrand,
+ cobrand_data => $update->cobrand_data,
+ lang => $update->lang,
+ );
+ $alert->confirm();
+
+ } elsif ( $c->user && ( my $alert = $c->user->alert_for_problem($c->stash->{update}->problem_id) ) ) {
+ $alert->disable();
+ }
+
+ return 1;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm
new file mode 100644
index 000000000..61d7d5cb1
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Reports.pm
@@ -0,0 +1,439 @@
+package FixMyStreet::App::Controller::Reports;
+use Moose;
+use namespace::autoclean;
+
+use File::Slurp;
+use List::MoreUtils qw(zip);
+use POSIX qw(strcoll);
+use mySociety::MaPit;
+use mySociety::VotingArea;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Reports - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=cut
+
+=head2 index
+
+Show the summary page of all reports.
+
+=cut
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->response->header('Cache-Control' => 'max-age=3600');
+
+ # Fetch all areas of the types we're interested in
+ my $areas_info;
+ eval {
+ my @area_types = $c->cobrand->area_types;
+ $areas_info = mySociety::MaPit::call('areas', \@area_types,
+ min_generation => $c->cobrand->area_min_generation
+ );
+ };
+ if ($@) {
+ $c->stash->{message} = _("Unable to look up areas in MaPit. Please try again later.") . ' ' .
+ sprintf(_('The error was: %s'), $@);
+ $c->stash->{template} = 'errors/generic.html';
+ }
+
+ # For each area, add its link and perhaps alter its name if we need to for
+ # places with the same name.
+ foreach (values %$areas_info) {
+ $_->{url} = $c->uri_for( '/reports/' . $c->cobrand->short_name( $_, $areas_info ) );
+ if ($_->{parent_area} && $_->{url} =~ /,|%2C/) {
+ $_->{name} .= ', ' . $areas_info->{$_->{parent_area}}{name};
+ }
+ }
+
+ $c->stash->{areas_info} = $areas_info;
+ my @keys = sort { strcoll($areas_info->{$a}{name}, $areas_info->{$b}{name}) } keys %$areas_info;
+ $c->stash->{areas_info_sorted} = [ map { $areas_info->{$_} } @keys ];
+
+ eval {
+ my $data = File::Slurp::read_file(
+ FixMyStreet->path_to( '../data/all-reports.json' )->stringify
+ );
+ my $j = JSON->new->utf8->decode($data);
+ $c->stash->{fixed} = $j->{fixed};
+ $c->stash->{open} = $j->{open};
+ };
+ if ($@) {
+ $c->stash->{message} = _("There was a problem showing the All Reports page. Please try again later.") . ' ' .
+ sprintf(_('The error was: %s'), $@);
+ $c->stash->{template} = 'errors/generic.html';
+ }
+}
+
+=head2 index
+
+Show the summary page for a particular council.
+
+=cut
+
+sub council : Path : Args(1) {
+ my ( $self, $c, $council ) = @_;
+ $c->detach( 'ward', [ $council ] );
+}
+
+=head2 index
+
+Show the summary page for a particular ward.
+
+=cut
+
+sub ward : Path : Args(2) {
+ my ( $self, $c, $council, $ward ) = @_;
+
+ $c->forward( 'council_check', [ $council ] );
+ $c->forward( 'ward_check', [ $ward ] )
+ if $ward;
+ $c->forward( 'load_parent' );
+ $c->forward( 'check_canonical_url', [ $council ] );
+ $c->forward( 'load_and_group_problems' );
+ $c->forward( 'sort_problems' );
+
+ my $council_short = $c->cobrand->short_name( $c->stash->{council}, $c->stash->{areas_info} );
+ $c->stash->{rss_url} = '/rss/reports/' . $council_short;
+ $c->stash->{rss_url} .= '/' . $c->cobrand->short_name( $c->stash->{ward} )
+ if $c->stash->{ward};
+
+ $c->stash->{council_url} = '/reports/' . $council_short;
+
+ my $pins = $c->stash->{pins};
+
+ $c->stash->{page} = 'reports'; # So the map knows to make clickable pins
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => @$pins ? $pins->[0]{latitude} : 0,
+ longitude => @$pins ? $pins->[0]{longitude} : 0,
+ area => $c->stash->{ward} ? $c->stash->{ward}->{id} : $c->stash->{council}->{id},
+ pins => $pins,
+ any_zoom => 1,
+ );
+
+ # List of wards
+ unless ($c->stash->{ward}) {
+ my $children = mySociety::MaPit::call('area/children', $c->stash->{council}->{id} );
+ foreach (values %$children) {
+ $_->{url} = $c->uri_for( $c->stash->{council_url}
+ . '/' . $c->cobrand->short_name( $_ )
+ );
+ }
+ $c->stash->{children} = $children;
+ }
+}
+
+sub rss_council : Regex('^rss/(reports|area)$') : Args(1) {
+ my ( $self, $c, $council ) = @_;
+ $c->detach( 'rss_ward', [ $council ] );
+}
+
+sub rss_ward : Regex('^rss/(reports|area)$') : Args(2) {
+ my ( $self, $c, $council, $ward ) = @_;
+
+ my ( $rss ) = $c->req->captures->[0];
+
+ $c->stash->{rss} = 1;
+
+ $c->forward( 'council_check', [ $council ] );
+ $c->forward( 'ward_check', [ $ward ] ) if $ward;
+
+ if ($rss eq 'area' && $c->stash->{council}{type} ne 'DIS' && $c->stash->{council}{type} ne 'CTY') {
+ # Two possibilites are the same for one-tier councils, so redirect one to the other
+ $c->detach( 'redirect_area' );
+ }
+
+ my $url = $c->cobrand->short_name( $c->stash->{council} );
+ $url .= '/' . $c->cobrand->short_name( $c->stash->{ward} ) if $c->stash->{ward};
+ $c->stash->{qs} = "/$url";
+
+ my @params;
+ push @params, $c->stash->{council}->{id} if $rss eq 'reports';
+ push @params, $c->stash->{ward}
+ ? $c->stash->{ward}->{id}
+ : $c->stash->{council}->{id};
+ $c->stash->{db_params} = [ @params ];
+
+ if ( $rss eq 'area' && $c->stash->{ward} ) {
+ # All problems within a particular ward
+ $c->stash->{type} = 'area_problems';
+ $c->stash->{title_params} = { NAME => $c->stash->{ward}{name} };
+ $c->stash->{db_params} = [ $c->stash->{ward}->{id} ];
+ } elsif ( $rss eq 'area' ) {
+ # Problems within a particular council
+ $c->stash->{type} = 'area_problems';
+ $c->stash->{title_params} = { NAME => $c->stash->{council}{name} };
+ $c->stash->{db_params} = [ $c->stash->{council}->{id} ];
+ } elsif ($c->stash->{ward}) {
+ # Problems sent to a council, restricted to a ward
+ $c->stash->{type} = 'ward_problems';
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{council}{name}, WARD => $c->stash->{ward}{name} };
+ $c->stash->{db_params} = [ $c->stash->{council}->{id}, $c->stash->{ward}->{id} ];
+ } else {
+ # Problems sent to a council
+ $c->stash->{type} = 'council_problems';
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{council}{name} };
+ $c->stash->{db_params} = [ $c->stash->{council}->{id}, $c->stash->{council}->{id} ];
+ }
+
+ # Send on to the RSS generation
+ $c->forward( '/rss/output' );
+}
+
+=head2 council_check
+
+This action checks the council name (or code) given in a URI exists, is valid
+and so on. If it is, it stores the Area in the stash, otherwise it redirects
+to the all reports page.
+
+=cut
+
+sub council_check : Private {
+ my ( $self, $c, $q_council ) = @_;
+
+ $q_council =~ s/\+/ /g;
+ $q_council =~ s/\.html//;
+
+ # Manual misspelling redirect
+ if ($q_council =~ /^rhondda cynon taff$/i) {
+ my $url = $c->uri_for( '/reports/rhondda+cynon+taf' );
+ $c->res->redirect( $url );
+ $c->detach();
+ }
+
+ # Check cobrand specific incantations - e.g. ONS codes for UK,
+ # Oslo/ kommunes sharing a name in Norway
+ return if $c->cobrand->reports_council_check( $c, $q_council );
+
+ # If we're passed an ID number (don't think this is used anywhere, it
+ # certainly shouldn't be), just look that up on MaPit and redirect
+ if ($q_council =~ /^\d+$/) {
+ my $council = mySociety::MaPit::call('area', $q_council);
+ $c->detach( 'redirect_index') if $council->{error};
+ $c->stash->{council} = $council;
+ $c->detach( 'redirect_area' );
+ }
+
+ # We must now have a string to check
+ my @area_types = $c->cobrand->area_types;
+ my $areas = mySociety::MaPit::call( 'areas', $q_council,
+ type => \@area_types,
+ min_generation => $c->cobrand->area_min_generation
+ );
+ if (keys %$areas == 1) {
+ ($c->stash->{council}) = values %$areas;
+ return;
+ } else {
+ foreach (keys %$areas) {
+ if (lc($areas->{$_}->{name}) eq lc($q_council) || $areas->{$_}->{name} =~ /^\Q$q_council\E (Borough|City|District|County) Council$/i) {
+ $c->stash->{council} = $areas->{$_};
+ return;
+ }
+ }
+ }
+
+ # No result, bad council name.
+ $c->detach( 'redirect_index' );
+}
+
+=head2 ward_check
+
+This action checks the ward name from a URI exists and is part of the right
+parent, already found with council_check. It either stores the ward Area if
+okay, or redirects to the council page if bad.
+This is currently only used in the UK, hence the use of mySociety::VotingArea.
+
+=cut
+
+sub ward_check : Private {
+ my ( $self, $c, $ward ) = @_;
+
+ $ward =~ s/\+/ /g;
+ $ward =~ s/\.html//;
+ $ward =~ s{_}{/}g;
+
+ my $council = $c->stash->{council};
+
+ my $qw = mySociety::MaPit::call('areas', $ward,
+ type => $mySociety::VotingArea::council_child_types,
+ min_generation => $c->cobrand->area_min_generation
+ );
+ foreach my $id (sort keys %$qw) {
+ if ($qw->{$id}->{parent_area} == $council->{id}) {
+ $c->stash->{ward} = $qw->{$id};
+ return;
+ }
+ }
+ # Given a false ward name
+ $c->detach( 'redirect_area' );
+}
+
+sub load_parent : Private {
+ my ( $self, $c ) = @_;
+
+ my $council = $c->stash->{council};
+ my $areas_info;
+ if ($council->{parent_area}) {
+ $c->stash->{areas_info} = mySociety::MaPit::call('areas', [ $council->{id}, $council->{parent_area} ])
+ } else {
+ $c->stash->{areas_info} = { $council->{id} => $council };
+ }
+}
+
+=head2 check_canonical_url
+
+Given an already found (case-insensitively) council, check what URL
+we are at and redirect accordingly if different.
+
+=cut
+
+sub check_canonical_url : Private {
+ my ( $self, $c, $q_council ) = @_;
+
+ my $council_short = $c->cobrand->short_name( $c->stash->{council}, $c->stash->{areas_info} );
+ my $url_short = URI::Escape::uri_escape_utf8($q_council);
+ $url_short =~ s/%2B/+/g;
+ $c->detach( 'redirect_area' ) unless $council_short eq $url_short;
+}
+
+sub load_and_group_problems : Private {
+ my ( $self, $c ) = @_;
+
+ my $page = $c->req->params->{p} || 1;
+
+ my $where = {
+ state => [ 'confirmed', 'fixed' ]
+ };
+ if ($c->stash->{ward}) {
+ $where->{areas} = { 'like', '%,' . $c->stash->{ward}->{id} . ',%' };
+ } elsif ($c->stash->{council}) {
+ $where->{areas} = { 'like', '%,' . $c->stash->{council}->{id} . ',%' };
+ }
+ my $problems = $c->cobrand->problems->search(
+ $where,
+ {
+ columns => [
+ 'id', 'council', 'state', 'areas', 'latitude', 'longitude', 'title',
+ { duration => { extract => "epoch from current_timestamp-lastupdate" } },
+ { age => { extract => "epoch from current_timestamp-confirmed" } },
+ ],
+ order_by => { -desc => 'lastupdate' },
+ rows => 100,
+ }
+ )->page( $page );
+ $c->stash->{pager} = $problems->pager;
+ $problems = $problems->cursor; # Raw DB cursor for speed
+
+ my ( %fixed, %open, @pins );
+ my $re_councils = join('|', keys %{$c->stash->{areas_info}});
+ my @cols = ( 'id', 'council', 'state', 'areas', 'latitude', 'longitude', 'title', 'duration', 'age' );
+ while ( my @problem = $problems->next ) {
+ my %problem = zip @cols, @problem;
+ if ( !$problem{council} ) {
+ # Problem was not sent to any council, add to possible councils
+ $problem{councils} = 0;
+ while ($problem{areas} =~ /,($re_councils)(?=,)/g) {
+ add_row( \%problem, $1, \%fixed, \%open, \@pins );
+ }
+ } else {
+ # Add to councils it was sent to
+ (my $council = $problem{council}) =~ s/\|.*$//;
+ my @council = split( /,/, $council );
+ $problem{councils} = scalar @council;
+ foreach ( @council ) {
+ next if $c->stash->{council} && $_ != $c->stash->{council}->{id};
+ add_row( \%problem, $_, \%fixed, \%open, \@pins );
+ }
+ }
+ }
+
+ $c->stash(
+ fixed => \%fixed,
+ open => \%open,
+ pins => \@pins,
+ );
+
+ return 1;
+}
+
+sub sort_problems : Private {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->stash->{council}->{id};
+ my $fixed = $c->stash->{fixed};
+ my $open = $c->stash->{open};
+
+ foreach (qw/new old/) {
+ $c->stash->{fixed}{$id}{$_} = [ sort { $a->{duration} <=> $b->{duration} } @{$fixed->{$id}{$_}} ]
+ if $fixed->{$id}{$_};
+ }
+ foreach (qw/new older unknown/) {
+ $c->stash->{open}{$id}{$_} = [ sort { $a->{age} <=> $b->{age} } @{$open->{$id}{$_}} ]
+ if $open->{$id}{$_};
+ }
+}
+
+sub redirect_index : Private {
+ my ( $self, $c ) = @_;
+ my $url = '/reports';
+ $c->res->redirect( $c->uri_for($url) );
+}
+
+sub redirect_area : Private {
+ my ( $self, $c ) = @_;
+ my $url = '';
+ $url .= "/rss" if $c->stash->{rss};
+ $url .= '/reports';
+ $url .= '/' . $c->cobrand->short_name( $c->stash->{council}, $c->stash->{areas_info} );
+ $url .= '/' . $c->cobrand->short_name( $c->stash->{ward} )
+ if $c->stash->{ward};
+ $c->res->redirect( $c->uri_for($url) );
+}
+
+my $fourweeks = 4*7*24*60*60;
+sub add_row {
+ my ( $problem, $council, $fixed, $open, $pins ) = @_;
+ my $duration_str = ( $problem->{duration} > 2 * $fourweeks ) ? 'old' : 'new';
+ my $type = ( $problem->{duration} > 2 * $fourweeks )
+ ? 'unknown'
+ : ($problem->{age} > $fourweeks ? 'older' : 'new');
+ # Fixed problems are either old or new
+ push @{$fixed->{$council}{$duration_str}}, $problem if $problem->{state} eq 'fixed';
+ # Open problems are either unknown, older, or new
+ push @{$open->{$council}{$type}}, $problem if $problem->{state} eq 'confirmed';
+
+ push @$pins, {
+ latitude => $problem->{latitude},
+ longitude => $problem->{longitude},
+ colour => $problem->{state} eq 'fixed' ? 'green' : 'red',
+ id => $problem->{id},
+ title => $problem->{title},
+ };
+}
+
+=head1 AUTHOR
+
+Matthew Somerville
+
+=head1 LICENSE
+
+Copyright (c) 2011 UK Citizens Online Democracy. All rights reserved.
+Licensed under the Affero GPL.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm
new file mode 100644
index 000000000..9cdf0b523
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Root.pm
@@ -0,0 +1,108 @@
+package FixMyStreet::App::Controller::Root;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller' }
+
+__PACKAGE__->config( namespace => '' );
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Root - Root Controller for FixMyStreet::App
+
+=head1 DESCRIPTION
+
+[enter your description here]
+
+=head1 METHODS
+
+=head2 auto
+
+Set up general things for this instance
+
+=cut
+
+sub auto : Private {
+ my ( $self, $c ) = @_;
+
+ # decide which cobrand this request should use
+ $c->setup_request();
+
+ return 1;
+}
+
+=head2 index
+
+Home page.
+
+If request includes certain parameters redirect to '/around' - this is to
+preserve old behaviour.
+
+=cut
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my @old_param_keys = ( 'pc', 'x', 'y', 'e', 'n', 'lat', 'lon' );
+ my %old_params = ();
+
+ foreach my $key (@old_param_keys) {
+ my $val = $c->req->param($key);
+ next unless $val;
+ $old_params{$key} = $val;
+ }
+
+ if ( scalar keys %old_params ) {
+ my $around_uri = $c->uri_for( '/around', \%old_params );
+ $c->res->redirect($around_uri);
+ return;
+ }
+
+}
+
+=head2 default
+
+Forward to the standard 404 error page
+
+=cut
+
+sub default : Path {
+ my ( $self, $c ) = @_;
+ $c->detach('/page_error_404_not_found');
+}
+
+=head2 page_error_404_not_found, page_error_410_gone
+
+ $c->detach( '/page_error_404_not_found', [$error_msg] );
+ $c->detach( '/page_error_410_gone', [$error_msg] );
+
+Display a 404 (not found) or 410 (gone) page. Pass in an optional error message in an arrayref.
+
+=cut
+
+sub page_error_404_not_found : Private {
+ my ( $self, $c, $error_msg ) = @_;
+ $c->stash->{template} = 'errors/page_error_404_not_found.html';
+ $c->stash->{error_msg} = $error_msg;
+ $c->response->status(404);
+}
+
+sub page_error_410_gone : Private {
+ my ( $self, $c, $error_msg ) = @_;
+ $c->stash->{template} = 'index.html';
+ $c->stash->{error} = $error_msg;
+ $c->response->status(410);
+}
+
+=head2 end
+
+Attempt to render a view, if needed.
+
+=cut
+
+sub end : ActionClass('RenderView') {
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm
new file mode 100755
index 000000000..78793d9c1
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Rss.pm
@@ -0,0 +1,342 @@
+package FixMyStreet::App::Controller::Rss;
+
+use Moose;
+use namespace::autoclean;
+use POSIX qw(strftime);
+use URI::Escape;
+use XML::RSS;
+
+use mySociety::Gaze;
+use mySociety::Locale;
+use mySociety::MaPit;
+use mySociety::Sundries qw(ordinal);
+use mySociety::Web qw(ent);
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Rss - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Catalyst Controller.
+
+=head1 METHODS
+
+=cut
+
+sub updates : LocalRegex('^(\d+)$') {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->req->captures->[0];
+ $c->forward( '/report/load_problem_or_display_error', [ $id ] );
+
+ $c->stash->{type} = 'new_updates';
+ $c->stash->{qs} = 'report/' . $id;
+ $c->stash->{db_params} = [ $id ];
+ $c->forward('output');
+}
+
+sub new_problems : Path('problems') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{type} = 'new_problems';
+ $c->forward('output');
+}
+
+# FIXME I don't think this is used - check
+#sub reports_to_council : Private {
+# my ( $self, $c ) = @_;
+#
+# my $id = $c->stash->{id};
+# $c->stash->{type} = 'council_problems';
+# $c->stash->{qs} = '/' . $id;
+# $c->stash->{db_params} = [ $id ];
+# $c->forward('output');
+#}
+
+sub reports_in_area : LocalRegex('^area/(\d+)$') {
+ my ( $self, $c ) = @_;
+
+ my $id = $c->req->captures->[0];
+ my $area = mySociety::MaPit::call('area', $id);
+ $c->stash->{type} = 'area_problems';
+ $c->stash->{qs} = '/' . $id;
+ $c->stash->{db_params} = [ $id ];
+ $c->stash->{title_params} = { NAME => $area->{name} };
+ $c->forward('output');
+}
+
+sub all_problems : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{type} = 'all_problems';
+ $c->forward('output');
+}
+
+sub local_problems_pc : Path('pc') : Args(1) {
+ my ( $self, $c, $query ) = @_;
+ $c->forward( 'local_problems_pc_distance', [ $query ] );
+}
+
+sub local_problems_pc_distance : Path('pc') : Args(2) {
+ my ( $self, $c, $query, $d ) = @_;
+
+ $c->forward( 'get_query_parameters', [ $d ] );
+ unless ( $c->forward( '/location/determine_location_from_pc', [ $query ] ) ) {
+ $c->res->redirect( '/alert' );
+ $c->detach();
+ }
+
+ my $pretty_query = $query;
+ $pretty_query = mySociety::PostcodeUtil::canonicalise_postcode($query)
+ if mySociety::PostcodeUtil::is_valid_postcode($query);
+
+ my $pretty_query_escaped = URI::Escape::uri_escape_utf8($pretty_query);
+ $pretty_query_escaped =~ s/%20/+/g;
+
+ $c->stash->{qs} = "?pc=$pretty_query_escaped";
+ $c->stash->{title_params} = { POSTCODE => $pretty_query };
+ $c->stash->{type} = 'postcode_local_problems';
+
+ $c->forward( 'local_problems_ll',
+ [ $c->stash->{latitude}, $c->stash->{longitude} ]
+ );
+
+}
+
+sub local_problems : LocalRegex('^(n|l)/([\d.-]+)[,/]([\d.-]+)(?:/(\d+))?$') {
+ my ( $self, $c ) = @_;
+
+ my ( $type, $a, $b, $d) = @{ $c->req->captures };
+ $c->forward( 'get_query_parameters', [ $d ] );
+
+ $c->detach( 'redirect_lat_lon', [ $a, $b ] )
+ if $type eq 'n';
+
+ $c->stash->{qs} = "?lat=$a;lon=$b";
+ $c->stash->{type} = 'local_problems';
+
+ $c->forward( 'local_problems_ll', [ $a, $b ] );
+}
+
+sub local_problems_ll : Private {
+ my ( $self, $c, $lat, $lon ) = @_;
+
+ # truncate the lat,lon for nicer urls
+ ( $lat, $lon ) = map { Utils::truncate_coordinate($_) } ( $lat, $lon );
+
+ my $d = $c->stash->{distance};
+ if ( $d ) {
+ $c->stash->{qs} .= ";d=$d";
+ $d = 100 if $d > 100;
+ } else {
+ $d = mySociety::Gaze::get_radius_containing_population( $lat, $lon, 200000 );
+ $d = int( $d * 10 + 0.5 ) / 10;
+ mySociety::Locale::in_gb_locale {
+ $d = sprintf("%f", $d);
+ }
+ }
+
+ $c->stash->{db_params} = [ $lat, $lon, $d ];
+
+ if ($c->stash->{state} ne 'all') {
+ $c->stash->{type} .= '_state';
+ push @{ $c->stash->{db_params} }, $c->stash->{state};
+ }
+
+ $c->forward('output');
+}
+
+sub output : Private {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{alert_type} = $c->model('DB::AlertType')->find( { ref => $c->stash->{type} } );
+ $c->detach( '/page_error_404_not_found', [ _('Unknown alert type') ] )
+ unless $c->stash->{alert_type};
+
+ $c->forward( 'query_main' );
+
+ # Do our own encoding
+ $c->stash->{rss} = new XML::RSS(
+ version => '2.0',
+ encoding => 'UTF-8',
+ stylesheet => $c->cobrand->feed_xsl,
+ encode_output => undef
+ );
+ $c->stash->{rss}->add_module(
+ prefix => 'georss',
+ uri => 'http://www.georss.org/georss'
+ );
+
+ while (my $row = $c->stash->{query_main}->fetchrow_hashref) {
+ $c->forward( 'add_row', [ $row ] );
+ }
+
+ $c->forward( 'add_parameters' );
+
+ my $out = $c->stash->{rss}->as_string;
+ my $uri = $c->uri_for( '/' . $c->req->path );
+ $out =~ s{<link>(.*?)</link>}{"<link>" . $c->uri_for( $1 ) . "</link><uri>$uri</uri>"}e;
+
+ $c->response->header('Content-Type' => 'application/xml; charset=utf-8');
+ $c->response->body( $out );
+}
+
+sub query_main : Private {
+ my ( $self, $c ) = @_;
+ my $alert_type = $c->stash->{alert_type};
+
+ my ( $site_restriction, $site_id ) = $c->cobrand->site_restriction( $c->cobrand->extra_data );
+ # Only apply a site restriction if the alert uses the problem table
+ $site_restriction = '' unless $alert_type->item_table eq 'problem';
+
+ # 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 . $site_restriction . ' order by '
+ . $alert_type->item_order;
+ my $rss_limit = mySociety::Config::get('RSS_LIMIT');
+ $query .= " limit $rss_limit" unless $c->stash->{type} =~ /^all/;
+
+ my $q = $c->model('DB::Alert')->result_source->storage->dbh->prepare($query);
+
+ $c->stash->{db_params} ||= [];
+ if ($query =~ /\?/) {
+ $c->detach( '/page_error_404_not_found', [ 'Missing parameter' ] )
+ unless @{ $c->stash->{db_params} };
+ $q->execute( @{ $c->stash->{db_params} } );
+ } else {
+ $q->execute();
+ }
+ $c->stash->{query_main} = $q;
+}
+
+sub add_row : Private {
+ my ( $self, $c, $row ) = @_;
+ my $alert_type = $c->stash->{alert_type};
+
+ $row->{name} = 'anonymous' if $row->{anonymous} || !$row->{name};
+
+ my $pubDate;
+ if ($row->{confirmed}) {
+ $row->{confirmed} =~ /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)/;
+ $pubDate = mySociety::Locale::in_gb_locale {
+ strftime("%a, %d %b %Y %H:%M:%S %z", $6, $5, $4, $3, $2-1, $1-1900, -1, -1, 0)
+ };
+ $row->{confirmed} = strftime("%e %B", $6, $5, $4, $3, $2-1, $1-1900, -1, -1, 0);
+ $row->{confirmed} =~ s/^\s+//;
+ $row->{confirmed} =~ s/^(\d+)/ordinal($1)/e if $c->stash->{lang_code} eq 'en-gb';
+ }
+
+ (my $title = _($alert_type->item_title)) =~ s/{{(.*?)}}/$row->{$1}/g;
+ (my $link = $alert_type->item_link) =~ s/{{(.*?)}}/$row->{$1}/g;
+ (my $desc = _($alert_type->item_description)) =~ s/{{(.*?)}}/$row->{$1}/g;
+ my $url = $c->uri_for( $link );
+ my %item = (
+ title => ent($title),
+ link => $url,
+ guid => $url,
+ description => ent(ent($desc)) # Yes, double-encoded, really.
+ );
+ $item{pubDate} = $pubDate if $pubDate;
+ $item{category} = $row->{category} if $row->{category};
+
+ if ($c->cobrand->allow_photo_display && $row->{photo}) {
+ my $key = $alert_type->item_table eq 'comment' ? 'c' : 'id';
+ $item{description} .= ent("\n<br><img src=\"". $c->cobrand->base_url . "/photo?$key=$row->{id}\">");
+ }
+ my $recipient_name = $c->cobrand->contact_name;
+ $item{description} .= ent("\n<br><a href='$url'>" .
+ sprintf(_("Report on %s"), $recipient_name) . "</a>");
+
+ if ($row->{latitude} || $row->{longitude}) {
+ $item{georss} = { point => "$row->{latitude} $row->{longitude}" };
+ }
+
+ $c->stash->{rss}->add_item( %item );
+}
+
+sub add_parameters : Private {
+ my ( $self, $c ) = @_;
+ my $alert_type = $c->stash->{alert_type};
+
+ my $row = {};
+ if ($alert_type->head_sql_query) {
+ my $q = $c->model('DB::Alert')->result_source->storage->dbh->prepare(
+ $alert_type->head_sql_query
+ );
+ if ($alert_type->head_sql_query =~ /\?/) {
+ $q->execute(@{ $c->stash->{db_params} });
+ } else {
+ $q->execute();
+ }
+ $row = $q->fetchrow_hashref;
+ }
+ foreach ( keys %{ $c->stash->{title_params} } ) {
+ $row->{$_} = $c->stash->{title_params}->{$_};
+ }
+
+ (my $title = _($alert_type->head_title)) =~ s/{{(.*?)}}/$row->{$1}/g;
+ (my $link = $alert_type->head_link) =~ s/{{(.*?)}}/$row->{$1}/g;
+ (my $desc = _($alert_type->head_description)) =~ s/{{(.*?)}}/$row->{$1}/g;
+
+ $c->stash->{rss}->channel(
+ title => ent($title),
+ link => $link . ($c->stash->{qs} || ''),
+ description => ent($desc),
+ language => 'en-gb',
+ );
+}
+
+sub local_problems_legacy : LocalRegex('^(\d+)[,/](\d+)(?:/(\d+))?$') {
+ my ( $self, $c ) = @_;
+ my ($x, $y, $d) = @{ $c->req->captures };
+ $c->forward( 'get_query_parameters', [ $d ] );
+
+ # 5000/31 as initial scale factor for these RSS feeds, now variable so redirect.
+ my $e = int( ($x * 5000/31) + 0.5 );
+ my $n = int( ($y * 5000/31) + 0.5 );
+ $c->detach( 'redirect_lat_lon', [ $e, $n ] );
+}
+
+sub get_query_parameters : Private {
+ my ( $self, $c, $d ) = @_;
+
+ $d = '' unless $d && $d =~ /^\d+$/;
+ $c->stash->{distance} = $d;
+
+ my $state = $c->req->param('state') || 'all';
+ $state = 'all' unless $state =~ /^(all|open|fixed)$/;
+ $c->stash->{state_qs} = "?state=$state" unless $state eq 'all';
+
+ $state = 'confirmed' if $state eq 'open';
+ $c->stash->{state} = $state;
+}
+
+sub redirect_lat_lon : Private {
+ my ( $self, $c, $e, $n ) = @_;
+ my ($lat, $lon) = Utils::convert_en_to_latlon_truncated($e, $n);
+
+ my $d_str = '';
+ $d_str = '/' . $c->stash->{distance} if $c->stash->{distance};
+ my $state_qs = '';
+ $state_qs = $c->stash->{state_qs} if $c->stash->{state_qs};
+ $c->res->redirect( "/rss/l/$lat,$lon" . $d_str . $state_qs );
+}
+
+=head1 AUTHOR
+
+Matthew Somerville
+
+=head1 LICENSE
+
+Copyright (c) 2011 UK Citizens Online Democracy. All rights reserved.
+Licensed under the Affero GPL.
+
+=cut
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Static.pm b/perllib/FixMyStreet/App/Controller/Static.pm
new file mode 100755
index 000000000..2e6bda28c
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Static.pm
@@ -0,0 +1,56 @@
+package FixMyStreet::App::Controller::Static;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Static - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Static pages Catalyst Controller. FAQ does some smarts to choose the correct
+template depending on language, will need extending at some point.
+
+=head1 METHODS
+
+=cut
+
+sub about : Global : Args(0) {
+ my ( $self, $c ) = @_;
+ # don't need to do anything here - should just pass through.
+}
+
+sub faq : Global : Args(0) {
+ my ( $self, $c ) = @_;
+
+ # There should be a faq template for each language in a cobrand or default.
+ # This is because putting the FAQ translations into the PO files is
+ # overkill.
+
+ # We rely on the list of languages for the site being restricted so that there
+ # will be a faq template for that language/cobrand combo.
+
+ my $lang_code = $c->stash->{lang_code};
+ my $template = "faq/faq-$lang_code.html";
+ $c->stash->{template} = $template;
+}
+
+sub fun : Global : Args(0) {
+ my ( $self, $c ) = @_;
+ # don't need to do anything here - should just pass through.
+}
+
+sub posters : Global : Args(0) {
+ my ( $self, $c ) = @_;
+}
+
+sub iphone : Global : Args(0) {
+ my ( $self, $c ) = @_;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm
new file mode 100644
index 000000000..9abef591d
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Tokens.pm
@@ -0,0 +1,240 @@
+package FixMyStreet::App::Controller::Tokens;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Tokens - Handle auth tokens
+
+=head1 DESCRIPTION
+
+Act on the various tokens that can be submitted.
+
+=head1 METHODS
+
+=cut
+
+=head2 confirm_problem
+
+ /P/([0-9A-Za-z]{16,18}).*$
+
+Confirm a problem - url appears in emails sent to users after they create the
+problem but are not logged in.
+
+=cut
+
+sub confirm_problem : Path('/P') {
+ my ( $self, $c, $token_code ) = @_;
+
+ my $auth_token =
+ $c->forward( 'load_auth_token', [ $token_code, 'problem' ] );
+
+ # Load the problem
+ my $data = $auth_token->data;
+ my $problem_id = $data->{id};
+ my $problem = $c->cobrand->problems->find( { id => $problem_id } )
+ || $c->detach('token_error');
+ $c->stash->{problem} = $problem;
+
+ # check that this email or domain are not the cause of abuse. If so hide it.
+ if ( $problem->is_from_abuser ) {
+ $problem->update(
+ { state => 'hidden', lastupdate => \'ms_current_timestamp()' } );
+ $c->stash->{template} = 'tokens/abuse.html';
+ return;
+ }
+
+ # We have a problem - confirm it if needed!
+ $problem->update(
+ {
+ state => 'confirmed',
+ confirmed => \'ms_current_timestamp()',
+ lastupdate => \'ms_current_timestamp()',
+ }
+ ) if $problem->state eq 'unconfirmed';
+
+ # Subscribe problem reporter to email updates
+ $c->stash->{report} = $c->stash->{problem};
+ $c->forward( '/report/new/create_reporter_alert' );
+
+ # log the problem creation user in to the site
+ if ( $data->{name} || $data->{password} ) {
+ $problem->user->name( $data->{name} ) if $data->{name};
+ $problem->user->password( $data->{password}, 1 ) if $data->{password};
+ $problem->user->update;
+ }
+ $c->authenticate( { email => $problem->user->email }, 'no_password' );
+ $c->set_session_cookie_expire(0);
+
+ return 1;
+}
+
+=head2 redirect_to_partial_problem
+
+ /P/...
+
+Redirect user to continue filling in a partial problem. The request is sent to
+'/report/new' which might redirect again to '/around' if there is no location
+found.
+
+=cut
+
+sub redirect_to_partial_problem : Path('/L') {
+ my ( $self, $c, $token_code ) = @_;
+
+ my $url = $c->uri_for( "/report/new", { partial => $token_code } );
+ return $c->res->redirect($url);
+}
+
+=head2 confirm_alert
+
+ /A/([0-9A-Za-z]{16,18}).*$
+
+Confirm an alert - url appears in emails sent to users after they create the
+alert but are not logged in.
+
+=cut
+
+sub confirm_alert : Path('/A') {
+ my ( $self, $c, $token_code ) = @_;
+
+ my $auth_token = $c->forward( 'load_auth_token', [ $token_code, 'alert' ] );
+
+ # Load the problem
+ my $alert_id = $auth_token->data->{id};
+ $c->stash->{confirm_type} = $auth_token->data->{type};
+ my $alert = $c->model('DB::Alert')->find( { id => $alert_id } )
+ || $c->detach('token_error');
+ $c->stash->{alert} = $alert;
+
+ # check that this email or domain are not the cause of abuse. If so hide it.
+ if ( $alert->is_from_abuser ) {
+ $c->stash->{template} = 'tokens/abuse.html';
+ return;
+ }
+
+ $c->authenticate( { email => $alert->user->email }, 'no_password' );
+ $c->set_session_cookie_expire(0);
+
+ $c->forward('/alert/confirm');
+
+ return 1;
+}
+
+=head2 confirm_update
+
+ /C/([0-9A-Za-z]{16,18}).*$
+
+Confirm an update - url appears in emails sent to users after they create the
+update but are not logged in.
+
+=cut
+
+sub confirm_update : Path('/C') {
+ my ( $self, $c, $token_code ) = @_;
+
+ my $auth_token =
+ $c->forward( 'load_auth_token', [ $token_code, 'comment' ] );
+
+ # Load the problem
+ my $data = $auth_token->data;
+ my $comment_id = $data->{id};
+ $c->stash->{add_alert} = $data->{add_alert};
+
+ my $comment = $c->model('DB::Comment')->find( { id => $comment_id } )
+ || $c->detach('token_error');
+ $c->stash->{update} = $comment;
+
+ # check that this email or domain are not the cause of abuse. If so hide it.
+ if ( $comment->is_from_abuser ) {
+ $c->stash->{template} = 'tokens/abuse.html';
+ return;
+ }
+
+ if ( $data->{name} || $data->{password} ) {
+ $comment->user->name( $data->{name} ) if $data->{name};
+ $comment->user->password( $data->{password}, 1 ) if $data->{password};
+ $comment->user->update;
+ }
+ $c->authenticate( { email => $comment->user->email }, 'no_password' );
+ $c->set_session_cookie_expire(0);
+
+ $c->forward('/report/update/confirm');
+
+ return 1;
+}
+
+sub load_questionnaire : Private {
+ my ( $self, $c, $token_code ) = @_;
+
+ # Set up error handling
+ $c->stash->{error_template} = 'errors/generic.html';
+ $c->stash->{message} = _("I'm afraid we couldn't validate that token. If you've copied the URL from an email, please check that you copied it exactly.\n");
+
+ my $auth_token = $c->forward( 'load_auth_token', [ $token_code, 'questionnaire' ] );
+ $c->stash->{id} = $auth_token->data;
+ $c->stash->{token} = $token_code;
+
+ my $questionnaire = $c->model('DB::Questionnaire')->find(
+ { id => $c->stash->{id} },
+ { prefetch => 'problem' }
+ );
+ $c->detach('/questionnaire/missing_problem') unless $questionnaire;
+ $c->stash->{questionnaire} = $questionnaire;
+}
+
+sub questionnaire : Path('/Q') : Args(1) {
+ my ( $self, $c, $token_code ) = @_;
+ $c->forward( 'load_questionnaire', [ $token_code ] );
+
+ $c->authenticate( { email => $c->stash->{questionnaire}->problem->user->email }, 'no_password' );
+ $c->set_session_cookie_expire(0);
+ $c->forward( '/questionnaire/index');
+}
+
+=head2 load_auth_token
+
+ my $auth_token =
+ $c->forward( 'load_auth_token', [ $token_code, $token_scope ] );
+
+
+Load the token if possible. If token is not found, or not valid detach to a nice
+error message.
+
+=cut
+
+sub load_auth_token : Private {
+ my ( $self, $c, $token_code, $scope ) = @_;
+
+ # clean the token of bad chars (in case of email client issues)
+ $token_code ||= '';
+ $token_code =~ s{[^a-zA-Z0-9]+}{}g;
+
+ # try to load the token
+ my $token = $c->model('DB::Token')->find(
+ {
+ scope => $scope,
+ token => $token_code,
+ }
+ ) || $c->detach('token_error');
+
+ return $token;
+}
+
+=head2 token_error
+
+Display an error page saying that there is something wrong with the token.
+
+=cut
+
+sub token_error : Private {
+ my ( $self, $c ) = @_;
+ $c->stash->{template} = $c->stash->{error_template} || 'tokens/error.html';
+ $c->detach;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;