aboutsummaryrefslogtreecommitdiffstats
path: root/perllib/FixMyStreet
diff options
context:
space:
mode:
Diffstat (limited to 'perllib/FixMyStreet')
-rw-r--r--perllib/FixMyStreet/App.pm58
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin.pm832
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Bodies.pm317
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm2
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm81
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm99
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Reports.pm523
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm140
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Roles.pm102
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Stats.pm46
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Templates.pm181
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Triage.pm163
-rw-r--r--perllib/FixMyStreet/App/Controller/Admin/Users.pm313
-rw-r--r--perllib/FixMyStreet/App/Controller/Alert.pm45
-rw-r--r--perllib/FixMyStreet/App/Controller/Around.pm29
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm164
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Profile.pm38
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Social.pm230
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact.pm42
-rw-r--r--perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm119
-rw-r--r--perllib/FixMyStreet/App/Controller/Dashboard.pm136
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Develop.pm31
-rw-r--r--perllib/FixMyStreet/App/Controller/Location.pm20
-rw-r--r--perllib/FixMyStreet/App/Controller/Moderate.pm10
-rw-r--r--perllib/FixMyStreet/App/Controller/My.pm31
-rw-r--r--perllib/FixMyStreet/App/Controller/Offline.pm109
-rw-r--r--perllib/FixMyStreet/App/Controller/Open311.pm26
-rw-r--r--perllib/FixMyStreet/App/Controller/Open311/Updates.pm88
-rw-r--r--perllib/FixMyStreet/App/Controller/Photo.pm37
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Questionnaire.pm6
-rw-r--r--perllib/FixMyStreet/App/Controller/Report.pm127
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/New.pm408
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/Update.pm73
-rw-r--r--perllib/FixMyStreet/App/Controller/Reports.pm158
-rw-r--r--perllib/FixMyStreet/App/Controller/Root.pm33
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Rss.pm26
-rwxr-xr-xperllib/FixMyStreet/App/Controller/Status.pm2
-rw-r--r--perllib/FixMyStreet/App/Controller/Test.pm60
-rw-r--r--perllib/FixMyStreet/App/Controller/Tokens.pm4
-rw-r--r--perllib/FixMyStreet/App/Form/I18N.pm13
-rw-r--r--perllib/FixMyStreet/App/Form/ManifestTheme.pm68
-rw-r--r--perllib/FixMyStreet/App/Form/ResponsePriority.pm50
-rw-r--r--perllib/FixMyStreet/App/Form/Role.pm67
-rw-r--r--perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm64
-rw-r--r--perllib/FixMyStreet/App/Model/PhotoSet.pm45
-rwxr-xr-xperllib/FixMyStreet/App/View/EmailText.pm29
-rw-r--r--perllib/FixMyStreet/App/View/Web.pm20
-rw-r--r--perllib/FixMyStreet/Auth/GoogleAuth.pm27
-rw-r--r--perllib/FixMyStreet/Cobrand/BathNES.pm112
-rw-r--r--perllib/FixMyStreet/Cobrand/Bexley.pm286
-rw-r--r--perllib/FixMyStreet/Cobrand/Borsetshire.pm8
-rw-r--r--perllib/FixMyStreet/Cobrand/Bristol.pm23
-rw-r--r--perllib/FixMyStreet/Cobrand/Bromley.pm161
-rw-r--r--perllib/FixMyStreet/Cobrand/Buckinghamshire.pm130
-rw-r--r--perllib/FixMyStreet/Cobrand/CheshireEast.pm145
-rw-r--r--perllib/FixMyStreet/Cobrand/Default.pm109
-rw-r--r--perllib/FixMyStreet/Cobrand/EastHerts.pm18
-rw-r--r--perllib/FixMyStreet/Cobrand/EastSussex.pm33
-rw-r--r--perllib/FixMyStreet/Cobrand/FixMyStreet.pm214
-rw-r--r--perllib/FixMyStreet/Cobrand/FixaMinGata.pm4
-rw-r--r--perllib/FixMyStreet/Cobrand/Greenwich.pm32
-rw-r--r--perllib/FixMyStreet/Cobrand/Hart.pm10
-rw-r--r--perllib/FixMyStreet/Cobrand/HighwaysEngland.pm158
-rw-r--r--perllib/FixMyStreet/Cobrand/Hounslow.pm174
-rw-r--r--perllib/FixMyStreet/Cobrand/IsleOfWight.pm233
-rw-r--r--perllib/FixMyStreet/Cobrand/Lincolnshire.pm52
-rw-r--r--perllib/FixMyStreet/Cobrand/Northamptonshire.pm98
-rw-r--r--perllib/FixMyStreet/Cobrand/Oxfordshire.pm143
-rw-r--r--perllib/FixMyStreet/Cobrand/Peterborough.pm92
-rw-r--r--perllib/FixMyStreet/Cobrand/Rutland.pm33
-rw-r--r--perllib/FixMyStreet/Cobrand/Stevenage.pm15
-rw-r--r--perllib/FixMyStreet/Cobrand/TfL.pm543
-rw-r--r--perllib/FixMyStreet/Cobrand/UK.pm42
-rw-r--r--perllib/FixMyStreet/Cobrand/UKCouncils.pm274
-rw-r--r--perllib/FixMyStreet/Cobrand/Warwickshire.pm12
-rw-r--r--perllib/FixMyStreet/Cobrand/Westminster.pm167
-rw-r--r--perllib/FixMyStreet/Cobrand/Zurich.pm262
-rw-r--r--perllib/FixMyStreet/DB/Factories.pm1
-rw-r--r--perllib/FixMyStreet/DB/RABXColumn.pm18
-rw-r--r--perllib/FixMyStreet/DB/Result/AdminLog.pm81
-rw-r--r--perllib/FixMyStreet/DB/Result/Body.pm45
-rw-r--r--perllib/FixMyStreet/DB/Result/Comment.pm17
-rw-r--r--perllib/FixMyStreet/DB/Result/Contact.pm55
-rw-r--r--perllib/FixMyStreet/DB/Result/ManifestTheme.pm47
-rw-r--r--perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm6
-rw-r--r--perllib/FixMyStreet/DB/Result/Problem.pm102
-rw-r--r--perllib/FixMyStreet/DB/Result/Role.pm53
-rw-r--r--perllib/FixMyStreet/DB/Result/User.pm147
-rw-r--r--perllib/FixMyStreet/DB/Result/UserRole.pm50
-rw-r--r--perllib/FixMyStreet/DB/ResultSet/Alert.pm7
-rw-r--r--perllib/FixMyStreet/DB/ResultSet/Comment.pm7
-rw-r--r--perllib/FixMyStreet/DB/ResultSet/Contact.pm64
-rw-r--r--perllib/FixMyStreet/DB/ResultSet/Problem.pm31
-rw-r--r--perllib/FixMyStreet/DB/ResultSet/State.pm6
-rw-r--r--perllib/FixMyStreet/DB/Schema.pm1
-rw-r--r--perllib/FixMyStreet/Email.pm10
-rw-r--r--perllib/FixMyStreet/Geocode.pm25
-rw-r--r--perllib/FixMyStreet/Geocode/Bexley.pm71
-rw-r--r--perllib/FixMyStreet/Geocode/Bing.pm9
-rw-r--r--perllib/FixMyStreet/Geocode/Google.pm3
-rw-r--r--perllib/FixMyStreet/Geocode/OSM.pm15
-rw-r--r--perllib/FixMyStreet/Geocode/Zurich.pm33
-rw-r--r--perllib/FixMyStreet/ImageMagick.pm42
-rw-r--r--perllib/FixMyStreet/Integrations/ExorRDI.pm250
-rw-r--r--perllib/FixMyStreet/Map/BathNES.pm20
-rw-r--r--perllib/FixMyStreet/Map/Bristol.pm54
-rw-r--r--perllib/FixMyStreet/Map/Bromley.pm12
-rw-r--r--perllib/FixMyStreet/Map/Buckinghamshire.pm41
-rw-r--r--perllib/FixMyStreet/Map/CheshireEast.pm70
-rw-r--r--perllib/FixMyStreet/Map/GoogleOL.pm2
-rw-r--r--perllib/FixMyStreet/Map/HighwaysEngland.pm8
-rw-r--r--perllib/FixMyStreet/Map/Hounslow.pm63
-rw-r--r--perllib/FixMyStreet/Map/IsleOfWight.pm63
-rw-r--r--perllib/FixMyStreet/Map/Lincolnshire.pm21
-rw-r--r--perllib/FixMyStreet/Map/MasterMap.pm40
-rw-r--r--perllib/FixMyStreet/Map/Northamptonshire.pm85
-rw-r--r--perllib/FixMyStreet/Map/OSM.pm40
-rw-r--r--perllib/FixMyStreet/Map/UKCouncilWMTS.pm55
-rw-r--r--perllib/FixMyStreet/Map/WMSBase.pm151
-rw-r--r--perllib/FixMyStreet/Map/WMTSBase.pm195
-rw-r--r--perllib/FixMyStreet/Map/WMXBase.pm199
-rw-r--r--perllib/FixMyStreet/MapIt.pm2
-rw-r--r--perllib/FixMyStreet/PhotoStorage.pm31
-rw-r--r--perllib/FixMyStreet/Queue/Item/Report.pm298
-rw-r--r--perllib/FixMyStreet/Roles/BoroughEmails.pm68
-rw-r--r--perllib/FixMyStreet/Roles/ConfirmOpen311.pm43
-rw-r--r--perllib/FixMyStreet/Roles/ConfirmValidation.pm8
-rw-r--r--perllib/FixMyStreet/Roles/ContactExtra.pm11
-rw-r--r--perllib/FixMyStreet/Roles/Extra.pm72
-rw-r--r--perllib/FixMyStreet/Roles/PhotoSet.pm13
-rw-r--r--perllib/FixMyStreet/Roles/Translatable.pm13
-rw-r--r--perllib/FixMyStreet/Script/Alerts.pm28
-rw-r--r--perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm5
-rw-r--r--perllib/FixMyStreet/Script/CreateSuperuser.pm20
-rw-r--r--perllib/FixMyStreet/Script/Inactive.pm59
-rw-r--r--perllib/FixMyStreet/Script/Questionnaires.pm4
-rw-r--r--perllib/FixMyStreet/Script/Reports.pm415
-rwxr-xr-xperllib/FixMyStreet/Script/UpdateAllReports.pm4
-rw-r--r--perllib/FixMyStreet/SendReport.pm35
-rw-r--r--perllib/FixMyStreet/SendReport/Email.pm38
-rw-r--r--perllib/FixMyStreet/SendReport/Email/Highways.pm24
-rw-r--r--perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm6
-rw-r--r--perllib/FixMyStreet/SendReport/Email/TfL.pm11
-rw-r--r--perllib/FixMyStreet/SendReport/Noop.pm5
-rw-r--r--perllib/FixMyStreet/SendReport/Open311.pm24
-rw-r--r--perllib/FixMyStreet/SendReport/Triage.pm20
-rw-r--r--perllib/FixMyStreet/SendReport/Zurich.pm5
-rw-r--r--perllib/FixMyStreet/Template.pm42
-rw-r--r--perllib/FixMyStreet/Template/Context.pm67
-rw-r--r--perllib/FixMyStreet/Template/SafeString.pm112
-rw-r--r--perllib/FixMyStreet/Template/Stash.pm75
-rw-r--r--perllib/FixMyStreet/Template/Variable.pm179
-rw-r--r--perllib/FixMyStreet/TestAppProve.pm4
-rw-r--r--perllib/FixMyStreet/TestMech.pm55
154 files changed, 9479 insertions, 3507 deletions
diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm
index 36f736cd2..6a41d93a9 100644
--- a/perllib/FixMyStreet/App.pm
+++ b/perllib/FixMyStreet/App.pm
@@ -13,6 +13,7 @@ use FixMyStreet::Email::Sender;
use FixMyStreet::PhotoStorage;
use Utils;
+use FixMyStreet::Auth::GoogleAuth;
use Path::Tiny 'path';
use Try::Tiny;
use Text::CSV;
@@ -21,12 +22,12 @@ use URI::QueryParam;
use Catalyst (
'Static::Simple',
- 'Unicode::Encoding',
'Session',
'Session::Store::DBIC',
'Session::State::Cookie', # FIXME - we're using our own override atm
'Authentication',
'SmartURI',
+ 'FixMyStreet::Session::RotateSession',
'FixMyStreet::Session::StoreSessions',
);
@@ -34,6 +35,12 @@ extends 'Catalyst';
our $VERSION = '0.01';
+my $store = { # Catalyst::Authentication::Store::DBIx::Class
+ class => 'DBIx::Class',
+ user_model => 'DB::User',
+ store_user_class => 'Catalyst::Authentication::Store::FixMyStreetUser',
+};
+
__PACKAGE__->config(
# Use REQUEST_URI, not PATH_INFO, to infer path. This fixes an issue
@@ -46,8 +53,6 @@ __PACKAGE__->config(
name => 'FixMyStreet::App',
- encoding => 'UTF-8',
-
# Disable deprecated behavior needed by old applications
disable_component_resolution_regex_fallback => 1,
@@ -83,20 +88,14 @@ __PACKAGE__->config(
},
],
},
- store => { # Catalyst::Authentication::Store::DBIx::Class
- class => 'DBIx::Class',
- user_model => 'DB::User',
- },
+ store => $store,
},
no_password => { # use post confirm etc
credential => { # Catalyst::Authentication::Credential::Password
class => 'Password',
password_type => 'none',
},
- store => { # Catalyst::Authentication::Store::DBIx::Class
- class => 'DBIx::Class',
- user_model => 'DB::User',
- },
+ store => $store,
},
access_token => {
use_session => 0,
@@ -106,10 +105,7 @@ __PACKAGE__->config(
# This means the token has to be 18 characters long (as generated by AuthToken)
token_lookup => { like => "%access_token,T18:TOKEN,%" },
},
- store => {
- class => 'DBIx::Class',
- user_model => 'DB::User',
- },
+ store => $store,
},
},
);
@@ -203,7 +199,7 @@ sub setup_request {
my $cobrand = $c->cobrand;
FixMyStreet::DB->schema->cobrand($cobrand);
- $cobrand->call_hook('add_response_headers');
+ $cobrand->add_response_headers;
# append the cobrand templates to the include path
$c->stash->{additional_template_paths} = $cobrand->path_to_web_templates;
@@ -346,7 +342,7 @@ sub send_email {
my $template = shift;
my $extra_stash_values = shift || {};
- my $sender = $c->config->{DO_NOT_REPLY_EMAIL};
+ my $sender = $c->cobrand->do_not_reply_email;
my $email = $c->construct_email($template, $extra_stash_values) or return;
my $result = 0;
@@ -364,7 +360,7 @@ sub construct_email {
my ($c, $template, $extra_stash_values) = @_;
$extra_stash_values //= {};
- my $sender = $c->config->{DO_NOT_REPLY_EMAIL};
+ my $sender = $c->cobrand->do_not_reply_email;
my $sender_name = $c->cobrand->contact_name;
# create the vars to pass to the email template
@@ -372,11 +368,12 @@ sub construct_email {
my $vars = {
from => [ $sender, _($sender_name) ],
%{ $c->stash },
- %$extra_stash_values,
additional_template_paths => \@include_path,
+ %$extra_stash_values,
};
- $vars->{site_name} = Utils::trim_text($c->view('Email')->render($c, 'site-name.txt', $vars));
- $vars->{signature} = $c->view('Email')->render($c, 'signature.txt', $vars);
+ $vars->{site_name} = Utils::trim_text($c->view('EmailText')->render($c, 'site-name.txt', $vars));
+ $vars->{signature} = $c->view('EmailText')->render($c, 'signature.txt', $vars);
+ $vars->{staging} = FixMyStreet->config('STAGING_SITE');
return if FixMyStreet::Email::is_abuser($c->model('DB')->schema, $vars->{to});
@@ -390,7 +387,7 @@ sub construct_email {
$c->log->debug("Error compiling HTML $template: $@") if $@;
my $data = {
- _body_ => $c->view('Email')->render( $c, $template, $vars ),
+ _body_ => $c->view('EmailText')->render( $c, $template, $vars ),
_attachments_ => $extra_stash_values->{attachments},
From => $vars->{from},
To => $vars->{to},
@@ -522,6 +519,23 @@ sub set_param {
$c->req->params->{$param} = $value;
}
+=head2 check_2fa
+
+Given a user's secret, verifies a submitted code.
+
+=cut
+
+sub check_2fa {
+ my ($c, $secret32) = @_;
+
+ if (my $code = $c->get_param('2fa_code')) {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ return 1 if $auth->verify($code, 2, $secret32);
+ $c->stash->{incorrect_code} = 1;
+ }
+ return 0;
+}
+
=head1 SEE ALSO
L<FixMyStreet::App::Controller::Root>, L<Catalyst>
diff --git a/perllib/FixMyStreet/App/Controller/Admin.pm b/perllib/FixMyStreet/App/Controller/Admin.pm
index 2f4669456..038cba9e5 100644
--- a/perllib/FixMyStreet/App/Controller/Admin.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin.pm
@@ -4,20 +4,9 @@ use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
-use Path::Class;
-use POSIX qw(strftime strcoll);
-use Digest::SHA qw(sha1_hex);
-use mySociety::EmailUtil qw(is_valid_email is_valid_email_list);
-use DateTime::Format::Strptime;
+use POSIX qw(strcoll);
use List::Util 'first';
-use List::MoreUtils 'uniq';
-use mySociety::ArrayUtils;
-use Text::CSV;
-use Try::Tiny;
-
-use FixMyStreet::SendReport;
use FixMyStreet::SMS;
-use Utils;
=head1 NAME
@@ -72,57 +61,27 @@ Displays some summary information for the requests.
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
- if ($c->cobrand->moniker eq 'zurich' && $c->stash->{admin_type} ne 'super') {
- return $c->cobrand->admin();
+ if ($c->cobrand->moniker eq 'zurich') {
+ if ($c->stash->{admin_type} eq 'super') {
+ $c->forward('/admin/stats/gather');
+ return 1;
+ } else {
+ return $c->cobrand->admin();
+ }
}
- $c->forward('/admin/stats/state');
-
my @unsent = $c->cobrand->problems->search( {
state => [ FixMyStreet::DB::Result::Problem::open_states() ],
whensent => undef,
bodies_str => { '!=', undef },
+ # Ignore very recent ones that probably just haven't been sent yet
+ confirmed => { '<', \"current_timestamp - '5 minutes'::interval" },
+ },
+ {
+ order_by => 'confirmed',
} )->all;
$c->stash->{unsent_reports} = \@unsent;
- my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction );
-
- my %alert_counts =
- map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all;
-
- $alert_counts{0} ||= 0;
- $alert_counts{1} ||= 0;
-
- $c->stash->{alerts} = \%alert_counts;
-
- my $contacts = $c->model('DB::Contact')->summary_count();
-
- my %contact_counts =
- map { $_->state => $_->get_column('state_count') } $contacts->all;
-
- $contact_counts{confirmed} ||= 0;
- $contact_counts{unconfirmed} ||= 0;
- $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed};
-
- $c->stash->{contacts} = \%contact_counts;
-
- my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction );
-
- my %questionnaire_counts = map {
- $_->get_column('answered') => $_->get_column('questionnaire_count')
- } $questionnaires->all;
- $questionnaire_counts{1} ||= 0;
- $questionnaire_counts{0} ||= 0;
-
- $questionnaire_counts{total} =
- $questionnaire_counts{0} + $questionnaire_counts{1};
- $c->stash->{questionnaires_pc} =
- $questionnaire_counts{total}
- ? sprintf( '%.1f',
- $questionnaire_counts{1} / $questionnaire_counts{total} * 100 )
- : _('n/a');
- $c->stash->{questionnaires} = \%questionnaire_counts;
-
$c->forward('fetch_all_bodies');
return 1;
@@ -143,47 +102,38 @@ sub timeline : Path( 'timeline' ) : Args(0) {
my %time;
- try {
- $c->model('DB')->schema->storage->sql_maker->quote_char( '"' );
- $c->model('DB')->schema->storage->sql_maker->name_sep( '.' );
+ my $probs = $c->cobrand->problems->timeline;
- my $probs = $c->cobrand->problems->timeline;
-
- foreach ($probs->all) {
- push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ };
- push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed;
- push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent;
- }
+ foreach ($probs->all) {
+ push @{$time{$_->created->epoch}}, { type => 'problemCreated', date => $_->created, obj => $_ };
+ push @{$time{$_->confirmed->epoch}}, { type => 'problemConfirmed', date => $_->confirmed, obj => $_ } if $_->confirmed;
+ push @{$time{$_->whensent->epoch}}, { type => 'problemSent', date => $_->whensent, obj => $_ } if $_->whensent;
+ }
- my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction );
+ my $questionnaires = $c->model('DB::Questionnaire')->timeline( $c->cobrand->restriction );
- foreach ($questionnaires->all) {
- push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ };
- push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered;
- }
+ foreach ($questionnaires->all) {
+ push @{$time{$_->whensent->epoch}}, { type => 'quesSent', date => $_->whensent, obj => $_ };
+ push @{$time{$_->whenanswered->epoch}}, { type => 'quesAnswered', date => $_->whenanswered, obj => $_ } if $_->whenanswered;
+ }
- my $updates = $c->cobrand->updates->timeline;
+ my $updates = $c->cobrand->updates->timeline;
- foreach ($updates->all) {
- push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ;
- }
+ foreach ($updates->all) {
+ push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_} ;
+ }
- my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction );
+ my $alerts = $c->model('DB::Alert')->timeline_created( $c->cobrand->restriction );
- foreach ($alerts->all) {
- push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ };
- }
+ foreach ($alerts->all) {
+ push @{$time{$_->whensubscribed->epoch}}, { type => 'alertSub', date => $_->whensubscribed, obj => $_ };
+ }
- $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction );
+ $alerts = $c->model('DB::Alert')->timeline_disabled( $c->cobrand->restriction );
- foreach ($alerts->all) {
- push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ };
- }
- } catch {
- die $_;
- } finally {
- $c->model('DB')->schema->storage->sql_maker->quote_char( '' );
- };
+ foreach ($alerts->all) {
+ push @{$time{$_->whendisabled->epoch}}, { type => 'alertDel', date => $_->whendisabled, obj => $_ };
+ }
$c->stash->{time} = \%time;
@@ -195,7 +145,7 @@ sub fetch_contacts : Private {
my $contacts = $c->stash->{body}->contacts->search(undef, { order_by => [ 'category' ] } );
$c->stash->{contacts} = $contacts;
- $c->stash->{live_contacts} = $contacts->not_deleted;
+ $c->stash->{live_contacts} = $contacts->not_deleted_admin;
$c->stash->{any_not_confirmed} = $contacts->search({ state => 'unconfirmed' })->count;
if ( $c->get_param('text') && $c->get_param('text') eq '1' ) {
@@ -221,167 +171,6 @@ sub fetch_languages : Private {
return 1;
}
-sub reports : Path('reports') {
- my ( $self, $c ) = @_;
-
- $c->stash->{edit_body_contacts} = 1
- if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}};
-
- my $query = {};
- if ( $c->cobrand->moniker eq 'zurich' ) {
- my $type = $c->stash->{admin_type};
- my $body = $c->stash->{body};
- if ( $type eq 'dm' ) {
- my @children = map { $_->id } $body->bodies->all;
- my @all = (@children, $body->id);
- $query = { bodies_str => \@all };
- } elsif ( $type eq 'sdm' ) {
- $query = { bodies_str => $body->id };
- }
- }
-
- my $order = $c->get_param('o') || 'created';
- my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1;
- $c->stash->{order} = $order;
- $c->stash->{dir} = $dir;
- $order .= ' desc' if $dir;
-
- my $p_page = $c->get_param('p') || 1;
- my $u_page = $c->get_param('u') || 1;
-
- return if $c->cobrand->call_hook(report_search_query => $query, $p_page, $u_page, $order);
-
- if (my $search = $c->get_param('search')) {
- $search = $self->trim($search);
-
- # In case an email address, wrapped in <...>
- if ($search =~ /^<(.*)>$/) {
- my $possible_email = $1;
- my $parsed = FixMyStreet::SMS->parse_username($possible_email);
- $search = $possible_email if $parsed->{email};
- }
-
- $c->stash->{searched} = $search;
-
- my $search_n = 0;
- $search_n = int($search) if $search =~ /^\d+$/;
-
- my $like_search = "%$search%";
-
- my $parsed = FixMyStreet::SMS->parse_username($search);
- my $valid_phone = $parsed->{phone};
- my $valid_email = $parsed->{email};
-
- # when DBIC creates the join it does 'JOIN users user' in the
- # SQL which makes PostgreSQL unhappy as user is a reserved
- # word. So look up user ID for email separately.
- my @user_ids = $c->model('DB::User')->search({
- email => { ilike => $like_search },
- }, { columns => [ 'id' ] } )->all;
- @user_ids = map { $_->id } @user_ids;
-
- my @user_ids_phone = $c->model('DB::User')->search({
- phone => { ilike => $like_search },
- }, { columns => [ 'id' ] } )->all;
- @user_ids_phone = map { $_->id } @user_ids_phone;
-
- if ($valid_email) {
- $query->{'-or'} = [
- 'me.user_id' => { -in => \@user_ids },
- ];
- } elsif ($valid_phone) {
- $query->{'-or'} = [
- 'me.user_id' => { -in => \@user_ids_phone },
- ];
- } elsif ($search =~ /^id:(\d+)$/) {
- $query->{'-or'} = [
- 'me.id' => int($1),
- ];
- } elsif ($search =~ /^area:(\d+)$/) {
- $query->{'-or'} = [
- 'me.areas' => { like => "%,$1,%" }
- ];
- } elsif ($search =~ /^ref:(\d+)$/) {
- $query->{'-or'} = [
- 'me.external_id' => { like => "%$1%" }
- ];
- } else {
- $query->{'-or'} = [
- 'me.id' => $search_n,
- 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] },
- 'me.external_id' => { ilike => $like_search },
- 'me.name' => { ilike => $like_search },
- 'me.title' => { ilike => $like_search },
- detail => { ilike => $like_search },
- bodies_str => { like => $like_search },
- cobrand_data => { like => $like_search },
- ];
- }
-
- my $problems = $c->cobrand->problems->search(
- $query,
- {
- rows => 50,
- order_by => [ \"(state='hidden')", \$order ]
- }
- )->page( $p_page );
-
- $c->stash->{problems} = [ $problems->all ];
- $c->stash->{problems_pager} = $problems->pager;
-
- if ($valid_email) {
- $query = [
- 'me.user_id' => { -in => \@user_ids },
- ];
- } elsif ($valid_phone) {
- $query = [
- 'me.user_id' => { -in => \@user_ids_phone },
- ];
- } elsif ($search =~ /^id:(\d+)$/) {
- $query = [
- 'me.id' => int($1),
- 'me.problem_id' => int($1),
- ];
- } elsif ($search =~ /^area:(\d+)$/) {
- $query = [];
- } else {
- $query = [
- 'me.id' => $search_n,
- 'problem.id' => $search_n,
- 'me.user_id' => { -in => [ @user_ids, @user_ids_phone ] },
- 'me.name' => { ilike => $like_search },
- text => { ilike => $like_search },
- 'me.cobrand_data' => { ilike => $like_search },
- ];
- }
-
- if (@$query) {
- my $updates = $c->cobrand->updates->search(
- {
- -or => $query,
- },
- {
- -select => [ 'me.*', qw/problem.bodies_str problem.state/ ],
- prefetch => [qw/problem/],
- rows => 50,
- order_by => [ \"(me.state='hidden')", \"(problem.state='hidden')", { -desc => 'me.created' } ]
- }
- )->page( $u_page );
- $c->stash->{updates} = [ $updates->all ];
- $c->stash->{updates_pager} = $updates->pager;
- }
-
- } else {
-
- my $problems = $c->cobrand->problems->search(
- $query,
- { order_by => $order, rows => 50 }
- )->page( $p_page );
- $c->stash->{problems} = [ $problems->all ];
- $c->stash->{problems_pager} = $problems->pager;
- }
-}
-
sub update_user : Private {
my ($self, $c, $object) = @_;
my $parsed = FixMyStreet::SMS->parse_username($c->get_param('username'));
@@ -395,471 +184,6 @@ sub update_user : Private {
return 0;
}
-sub report_edit_display : Private {
- my ( $self, $c ) = @_;
-
- my $problem = $c->stash->{problem};
-
- $c->stash->{page} = 'admin';
- FixMyStreet::Map::display_map(
- $c,
- latitude => $problem->latitude,
- longitude => $problem->longitude,
- pins => $problem->used_map
- ? [ {
- latitude => $problem->latitude,
- longitude => $problem->longitude,
- colour => $c->cobrand->pin_colour($problem, 'admin'),
- type => 'big',
- draggable => 1,
- } ]
- : [],
- print_report => 1,
- );
-}
-
-sub report_edit : Path('report_edit') : Args(1) {
- my ( $self, $c, $id ) = @_;
-
- my $problem = $c->cobrand->problems->search( { id => $id } )->first;
-
- $c->detach( '/page_error_404_not_found', [] )
- unless $problem;
-
- unless (
- $c->cobrand->moniker eq 'zurich'
- || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids)
- ) {
- $c->detach( '/page_error_403_access_denied', [] );
- }
-
- $c->stash->{problem} = $problem;
- if ( $problem->extra ) {
- my @fields;
- if ( my $fields = $problem->get_extra_fields ) {
- for my $field ( @{$fields} ) {
- my $name = $field->{description} ?
- "$field->{description} ($field->{name})" :
- "$field->{name}";
- push @fields, { name => $name, val => $field->{value} };
- }
- }
- my $extra = $problem->get_extra_metadata;
- if ( $extra->{duplicates} ) {
- push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) };
- delete $extra->{duplicates};
- }
- for my $key ( keys %$extra ) {
- push @fields, { name => $key, val => $extra->{$key} };
- }
-
- $c->stash->{extra_fields} = \@fields;
- }
-
- $c->forward('/auth/get_csrf_token');
-
- $c->forward('categories_for_point');
-
- $c->forward('alerts_for_report');
-
- $c->forward('check_username_for_abuse', [ $problem->user ] );
-
- $c->stash->{updates} =
- [ $c->model('DB::Comment')
- ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } )
- ->all ];
-
- if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) {
- $self->rotate_photo($c, $problem, @$rotate_photo_param);
- $c->detach('report_edit_display');
- }
-
- if ( $c->cobrand->moniker eq 'zurich' ) {
- my $done = $c->cobrand->admin_report_edit();
- $c->detach('report_edit_display') if $done;
- }
-
- if ( $c->get_param('resend') ) {
- $c->forward('/auth/check_csrf_token');
-
- $problem->resend;
- $problem->update();
- $c->stash->{status_message} =
- '<p><em>' . _('That problem will now be resent.') . '</em></p>';
-
- $c->forward( 'log_edit', [ $id, 'problem', 'resend' ] );
- }
- elsif ( $c->get_param('mark_sent') ) {
- $c->forward('/auth/check_csrf_token');
- $problem->update({ whensent => \'current_timestamp' })->discard_changes;
- $c->stash->{status_message} = '<p><em>' . _('That problem has been marked as sent.') . '</em></p>';
- $c->forward( 'log_edit', [ $id, 'problem', 'marked sent' ] );
- }
- elsif ( $c->get_param('flaguser') ) {
- $c->forward('users/flag');
- $c->stash->{problem}->discard_changes;
- }
- elsif ( $c->get_param('removeuserflag') ) {
- $c->forward('users/flag_remove');
- $c->stash->{problem}->discard_changes;
- }
- elsif ( $c->get_param('banuser') ) {
- $c->forward('users/ban');
- }
- elsif ( $c->get_param('submit') ) {
- $c->forward('/auth/check_csrf_token');
-
- my $old_state = $problem->state;
-
- my %columns = (
- flagged => $c->get_param('flagged') ? 1 : 0,
- non_public => $c->get_param('non_public') ? 1 : 0,
- );
- foreach (qw/state anonymous title detail name external_id external_body external_team/) {
- $columns{$_} = $c->get_param($_);
- }
- $problem->set_inflated_columns(\%columns);
-
- if ($c->get_param('closed_updates')) {
- $problem->set_extra_metadata(closed_updates => 1);
- } else {
- $problem->unset_extra_metadata('closed_updates');
- }
-
- $c->forward( '/admin/report_edit_category', [ $problem, $problem->state ne $old_state ] );
- $c->forward('update_user', [ $problem ]);
-
- # Deal with photos
- my $remove_photo_param = $self->_get_remove_photo_param($c);
- if ($remove_photo_param) {
- $self->remove_photo($c, $problem, $remove_photo_param);
- }
-
- if ($problem->state eq 'hidden') {
- $problem->get_photoset->delete_cached;
- }
-
- if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) {
- $problem->confirmed( \'current_timestamp' );
- }
-
- $problem->lastupdate( \'current_timestamp' );
- $problem->update;
-
- if ( $problem->state ne $old_state ) {
- $c->forward( 'log_edit', [ $id, 'problem', 'state_change' ] );
-
- my $name = $c->user->moderating_user_name;
- my $extra = { is_superuser => 1 };
- if ($c->user->from_body) {
- delete $extra->{is_superuser};
- $extra->{is_body_user} = $c->user->from_body->id;
- }
- my $timestamp = \'current_timestamp';
- $problem->add_to_comments( {
- text => $c->stash->{update_text} || '',
- created => $timestamp,
- confirmed => $timestamp,
- user_id => $c->user->id,
- name => $name,
- mark_fixed => 0,
- anonymous => 0,
- state => 'confirmed',
- problem_state => $problem->state,
- extra => $extra
- } );
- }
- $c->forward( 'log_edit', [ $id, 'problem', 'edit' ] );
-
- $c->stash->{status_message} =
- '<p><em>' . _('Updated!') . '</em></p>';
-
- # do this here otherwise lastupdate and confirmed times
- # do not display correctly
- $problem->discard_changes;
- }
-
- $c->detach('report_edit_display');
-}
-
-=head2 report_edit_category
-
-Handles changing a problem's category and the complexity that comes with it.
-Returns 1 if category changed, 0 if no change.
-
-=cut
-
-sub report_edit_category : Private {
- my ($self, $c, $problem, $no_comment) = @_;
-
- if ((my $category = $c->get_param('category')) ne $problem->category) {
- my $category_old = $problem->category;
- $problem->category($category);
- my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}};
- my @new_body_ids = map { $_->body_id } @contacts;
- # If the report has changed bodies (and not to a subset!) we need to resend it
- my %old_map = map { $_ => 1 } @{$problem->bodies_str_ids};
- if (grep !$old_map{$_}, @new_body_ids) {
- $problem->resend;
- }
- # If the send methods of the old/new contacts differ we need to resend the report
- my @new_send_methods = uniq map {
- ( $_->body->can_be_devolved && $_->send_method ) ?
- $_->send_method : $_->body->send_method
- ? $_->body->send_method
- : $c->cobrand->_fallback_body_sender()->{method};
- } @contacts;
- my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email");
- if (grep !$old_send_methods{$_}, @new_send_methods) {
- $problem->resend;
- }
-
- $problem->bodies_str(join( ',', @new_body_ids ));
- my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*';
- if ($no_comment) {
- $c->stash->{update_text} = $update_text;
- } else {
- $problem->add_to_comments({
- text => $update_text,
- created => \'current_timestamp',
- confirmed => \'current_timestamp',
- user_id => $c->user->id,
- name => $c->user->from_body ? $c->user->from_body->name : $c->user->name,
- state => 'confirmed',
- mark_fixed => 0,
- anonymous => 0,
- });
- }
- return 1;
- }
- return 0;
-}
-
-=head2 report_edit_location
-
-Handles changing a problem's location and the complexity that comes with it.
-For now, we reject the new location if the new location and old locations aren't
-covered by the same body.
-
-Returns 2 if the new position (if any) is acceptable and changed,
-1 if acceptable and unchanged, undef otherwise.
-
-NB: This must be called before report_edit_category, as that might modify
-$problem->bodies_str.
-
-=cut
-
-sub report_edit_location : Private {
- my ($self, $c, $problem) = @_;
-
- return 1 unless $c->forward('/location/determine_location_from_coords');
-
- my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude;
- if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) {
- # The two actions below change the stash, setting things up for e.g. a
- # new report. But here we're only doing it in order to check the found
- # bodies match; we don't want to overwrite the existing report data if
- # this lookup is bad. So let's save the stash and restore it after the
- # comparison.
- my $safe_stash = { %{$c->stash} };
- $c->stash->{fetch_all_areas} = 1;
- $c->stash->{area_check_action} = 'admin';
- $c->forward('/council/load_and_check_areas', []);
- $c->forward('/report/new/setup_categories_and_bodies');
- my %allowed_bodies = map { $_ => 1 } @{$problem->bodies_str_ids};
- my @new_bodies = keys %{$c->stash->{bodies_to_list}};
- my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies;
- $c->stash($safe_stash);
- return unless $bodies_match;
- $problem->latitude($c->stash->{latitude});
- $problem->longitude($c->stash->{longitude});
- my $areas = $c->stash->{all_areas_mapit};
- $problem->areas( ',' . join( ',', sort keys %$areas ) . ',' );
- return 2;
- }
- return 1;
-}
-
-sub categories_for_point : Private {
- my ($self, $c) = @_;
-
- $c->stash->{report} = $c->stash->{problem};
- # We have a report, stash its location
- $c->forward('/report/new/determine_location_from_report');
- # Look up the areas for this location
- my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ];
- $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]);
- # As with a new report, fetch the bodies/categories
- $c->forward('/report/new/setup_categories_and_bodies');
-
- # Remove the "Pick a category" option
- shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}};
-
- $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} };
-}
-
-sub alerts_for_report : Private {
- my ($self, $c) = @_;
-
- $c->stash->{alert_count} = $c->model('DB::Alert')->search({
- alert_type => 'new_updates',
- parameter => $c->stash->{report}->id,
- confirmed => 1,
- whendisabled => undef,
- })->count();
-}
-
-sub templates : Path('templates') : Args(0) {
- my ( $self, $c ) = @_;
-
- my $user = $c->user;
-
- if ($user->is_superuser) {
- $c->forward('fetch_all_bodies');
- $c->stash->{template} = 'admin/templates_index.html';
- } elsif ( $user->from_body ) {
- $c->forward('load_template_body', [ $user->from_body->id ]);
- $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) );
- } else {
- $c->detach( '/page_error_404_not_found', [] );
- }
-}
-
-sub templates_view : Path('templates') : Args(1) {
- my ($self, $c, $body_id) = @_;
-
- $c->forward('load_template_body', [ $body_id ]);
-
- my @templates = $c->stash->{body}->response_templates->search(
- undef,
- {
- order_by => 'title'
- }
- );
-
- $c->stash->{response_templates} = \@templates;
-
- $c->stash->{template} = 'admin/templates.html';
-}
-
-sub template_edit : Path('templates') : Args(2) {
- my ( $self, $c, $body_id, $template_id ) = @_;
-
- $c->forward('load_template_body', [ $body_id ]);
-
- my $template;
- if ($template_id eq 'new') {
- $template = $c->stash->{body}->response_templates->new({});
- }
- else {
- $template = $c->stash->{body}->response_templates->find( $template_id )
- or $c->detach( '/page_error_404_not_found', [] );
- }
-
- $c->forward('fetch_contacts');
- my @contacts = $template->contacts->all;
- my @live_contacts = $c->stash->{live_contacts}->all;
- my %active_contacts = map { $_->id => 1 } @contacts;
- my @all_contacts = map { {
- id => $_->id,
- category => $_->category_display,
- active => $active_contacts{$_->id},
- email => $_->email,
- } } @live_contacts;
- $c->stash->{contacts} = \@all_contacts;
-
- # bare block to use 'last' if form is invalid.
- if ($c->req->method eq 'POST') { {
- if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) {
- $template->contact_response_templates->delete_all;
- $template->delete;
- } else {
- my @live_contact_ids = map { $_->id } @live_contacts;
- my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
- my %new_contacts = map { $_ => 1 } @new_contact_ids;
- for my $contact (@all_contacts) {
- $contact->{active} = $new_contacts{$contact->{id}};
- }
-
- $template->title( $c->get_param('title') );
- $template->text( $c->get_param('text') );
- $template->state( $c->get_param('state') );
- $template->external_status_code( $c->get_param('external_status_code') );
-
- if ( $template->state && $template->external_status_code ) {
- $c->stash->{errors} ||= {};
- $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously.");
- $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously.");
- }
-
- $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 );
- if ($template->auto_response) {
- my @check_contact_ids = @new_contact_ids;
- # If the new template has not specific categories (i.e. it
- # applies to all categories) then we need to check each of those
- # category ids for existing auto-response templates.
- if (!scalar @check_contact_ids) {
- @check_contact_ids = @live_contact_ids;
- }
- my $query = {
- 'auto_response' => 1,
- 'contact.id' => [ @check_contact_ids, undef ],
- -or => {
- $template->state ? ('me.state' => $template->state) : (),
- $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (),
- },
- };
- if ($template->in_storage) {
- $query->{'me.id'} = { '!=', $template->id };
- }
- if ($c->stash->{body}->response_templates->search($query, {
- join => { 'contact_response_templates' => 'contact' },
- })->count) {
- $c->stash->{errors} ||= {};
- $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state.");
- }
- }
-
- last if $c->stash->{errors};
-
- $template->update_or_insert;
- $template->contact_response_templates->search({
- contact_id => { '!=' => \@new_contact_ids },
- })->delete;
- foreach my $contact_id (@new_contact_ids) {
- $template->contact_response_templates->find_or_create({
- contact_id => $contact_id,
- });
- }
- }
-
- $c->res->redirect( $c->uri_for( 'templates', $c->stash->{body}->id ) );
- } }
-
- $c->stash->{response_template} = $template;
-
- $c->stash->{template} = 'admin/template_edit.html';
-}
-
-sub load_template_body : Private {
- my ($self, $c, $body_id) = @_;
-
- my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich';
- my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id);
-
- unless ( $zurich_user || $has_permission ) {
- $c->detach( '/page_error_404_not_found', [] );
- }
-
- # Regular users can only view their own body's templates
- if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) {
- $c->res->redirect( $c->uri_for( 'templates', $c->user->from_body->id ) );
- }
-
- $c->stash->{body} = $c->model('DB::Body')->find($body_id)
- or $c->detach( '/page_error_404_not_found', [] );
-}
-
sub update_edit : Path('update_edit') : Args(1) {
my ( $self, $c, $id ) = @_;
@@ -872,8 +196,8 @@ sub update_edit : Path('update_edit') : Args(1) {
$c->stash->{update} = $update;
- if (my $rotate_photo_param = $self->_get_rotate_photo_param($c)) {
- $self->rotate_photo($c, $update, @$rotate_photo_param);
+ if (my $rotate_photo_param = $c->forward('_get_rotate_photo_param')) {
+ $c->forward('rotate_photo', [ $update, @$rotate_photo_param ]);
return 1;
}
@@ -907,18 +231,17 @@ sub update_edit : Path('update_edit') : Args(1) {
$edited = 1;
}
- my $remove_photo_param = $self->_get_remove_photo_param($c);
+ my $remove_photo_param = $c->forward('_get_remove_photo_param');
if ($remove_photo_param) {
- $self->remove_photo($c, $update, $remove_photo_param);
+ $c->forward('remove_photo', [$update, $remove_photo_param]);
}
- $c->stash->{status_message} = '<p><em>' . _('Updated!') . '</em></p>';
+ $c->stash->{status_message} = _('Updated!');
# Must call update->hide while it's not hidden (so is_latest works)
if ($new_state eq 'hidden') {
my $outcome = $update->hide;
- $c->stash->{status_message} .=
- '<p><em>' . _('Problem marked as open.') . '</em></p>'
+ $c->stash->{status_message} .= _('Problem marked as open.')
if $outcome->{reopened};
}
@@ -1014,8 +337,8 @@ sub set_allowed_pages : Private {
sub get_user : Private {
my ( $self, $c ) = @_;
- my $user = $c->req->remote_user();
- $user ||= ($c->user && $c->user->name);
+ my $user = ($c->user && $c->user->name);
+ $user ||= $c->req->remote_user();
$user ||= '';
return $user;
@@ -1075,7 +398,7 @@ Rotate a photo 90 degrees left or right
=cut
# returns index of photo to rotate, if any
-sub _get_rotate_photo_param {
+sub _get_rotate_photo_param : Private {
my ($self, $c) = @_;
my $key = first { /^rotate_photo/ } keys %{ $c->req->params } or return;
my ($index) = $key =~ /(\d+)$/;
@@ -1105,7 +428,7 @@ Remove a photo from a report
=cut
# Returns index of photo(s) to remove, if any
-sub _get_remove_photo_param {
+sub _get_remove_photo_param : Private {
my ($self, $c) = @_;
return 'ALL' if $c->get_param('remove_photo');
@@ -1117,8 +440,8 @@ sub _get_remove_photo_param {
sub remove_photo : Private {
my ($self, $c, $object, $keys) = @_;
if ($keys eq 'ALL') {
- $object->photo(undef);
$object->get_photoset->delete_cached;
+ $object->photo(undef);
} else {
my $fileids = $object->get_photoset->remove_images($keys);
$object->photo($fileids);
@@ -1194,31 +517,46 @@ sub update_extra_fields : Private {
my $meta = {};
$meta->{code} = $c->get_param("metadata[$i].code");
next unless $meta->{code};
+
$meta->{order} = int $c->get_param("metadata[$i].order");
- $meta->{datatype} = $c->get_param("metadata[$i].datatype");
- my $required = $c->get_param("metadata[$i].required") && $c->get_param("metadata[$i].required") eq 'on';
- $meta->{required} = $required ? 'true' : 'false';
- my $notice = $c->get_param("metadata[$i].notice") && $c->get_param("metadata[$i].notice") eq 'on';
- $meta->{variable} = $notice ? 'false' : 'true';
- $meta->{description} = $c->get_param("metadata[$i].description");
- $meta->{datatype_description} = $c->get_param("metadata[$i].datatype_description");
- $meta->{automated} = $c->get_param("metadata[$i].automated")
- if $c->get_param("metadata[$i].automated");
-
- if ( $meta->{datatype} eq "singlevaluelist" ) {
- $meta->{values} = [];
- my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key};
- my @vindices = grep { /$re/ } keys %{ $c->req->params };
- @vindices = sort map { /values\[(\d+)\]/ } @vindices;
- foreach my $j (@vindices) {
- my $name = $c->get_param("metadata[$i].values[$j].name");
- my $key = $c->get_param("metadata[$i].values[$j].key");
- push(@{$meta->{values}}, {
- name => $name,
- key => $key,
- }) if $name;
+ $meta->{protected} = $c->get_param("metadata[$i].protected") ? 'true' : 'false';
+
+ my $behaviour = $c->get_param("metadata[$i].behaviour") || 'question';
+ if ($behaviour eq 'question') {
+ $meta->{required} = $c->get_param("metadata[$i].required") ? 'true' : 'false';
+ $meta->{variable} = 'true';
+ my $desc = $c->get_param("metadata[$i].description");
+ $meta->{description} = FixMyStreet::Template::sanitize($desc);
+ $meta->{datatype} = $c->get_param("metadata[$i].datatype");
+
+ if ( $meta->{datatype} eq "singlevaluelist" ) {
+ $meta->{values} = [];
+ my $re = qr{^metadata\[$i\]\.values\[\d+\]\.key};
+ my @vindices = grep { /$re/ } keys %{ $c->req->params };
+ @vindices = sort map { /values\[(\d+)\]/ } @vindices;
+ foreach my $j (@vindices) {
+ my $name = $c->get_param("metadata[$i].values[$j].name");
+ my $key = $c->get_param("metadata[$i].values[$j].key");
+ my $disable = $c->get_param("metadata[$i].values[$j].disable");
+ my $disable_message = $c->get_param("metadata[$i].values[$j].disable_message");
+ push(@{$meta->{values}}, {
+ name => $name,
+ key => $key,
+ $disable ? (disable => 1, disable_message => $disable_message) : (),
+ }) if $name;
+ }
}
+ } elsif ($behaviour eq 'notice') {
+ $meta->{variable} = 'false';
+ my $desc = $c->get_param("metadata[$i].description");
+ $meta->{description} = FixMyStreet::Template::sanitize($desc);
+ $meta->{disable_form} = $c->get_param("metadata[$i].disable_form") ? 'true' : 'false';
+ } elsif ($behaviour eq 'hidden') {
+ $meta->{automated} = 'hidden_field';
+ } elsif ($behaviour eq 'server') {
+ $meta->{automated} = 'server_set';
}
+
push @extra_fields, $meta;
}
@extra_fields = sort { $a->{order} <=> $b->{order} } @extra_fields;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
index 0e47d2238..6ae068cd9 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Bodies.pm
@@ -51,27 +51,7 @@ sub index : Path : Args(0) {
my $posted = $c->get_param('posted') || '';
if ( $posted eq 'body' ) {
- $c->forward('check_for_super_user');
- $c->forward('/auth/check_csrf_token');
-
- my $values = $c->forward('body_params');
- unless ( keys %{$c->stash->{body_errors}} ) {
- my $body = $c->model('DB::Body')->create( $values->{params} );
- if ($values->{extras}) {
- $body->set_extra_metadata( $_ => $values->{extras}->{$_} )
- for keys %{$values->{extras}};
- $body->update;
- }
- my @area_ids = $c->get_param_list('area_ids');
- foreach (@area_ids) {
- $c->model('DB::BodyArea')->create( { body => $body, area_id => $_ } );
- }
-
- $c->stash->{object} = $body;
- $c->stash->{translation_col} = 'name';
- $c->forward('update_translations');
- $c->stash->{updated} = _('New body added');
- }
+ $c->forward('update_body', [ undef, _('New body added') ]);
}
$c->forward( '/admin/fetch_all_bodies' );
@@ -147,6 +127,8 @@ sub edit : Chained('body') : PathPart('') : Args(0) {
# to display email addresses as text
$c->stash->{template} = 'admin/bodies/body.html';
$c->forward('/admin/fetch_contacts');
+ $c->stash->{contacts} = [ $c->stash->{contacts}->all ];
+ $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]);
return 1;
}
@@ -158,7 +140,8 @@ sub category : Chained('body') : PathPart('') {
$c->forward( '/auth/get_csrf_token' );
my $contact = $c->stash->{body}->contacts->search( { category => $category } )->first;
- $c->stash->{contact} = $contact;
+ $c->detach( '/page_error_404_not_found', [] ) unless $contact;
+ $c->stash->{contact} = $c->stash->{current_contact} = $contact;
$c->stash->{translation_col} = 'category';
$c->stash->{object} = $c->stash->{contact};
@@ -220,140 +203,199 @@ sub check_for_super_user : Private {
sub update_contacts : Private {
my ( $self, $c ) = @_;
- my $posted = $c->get_param('posted');
- my $editor = $c->forward('/admin/get_user');
-
+ my $posted = $c->get_param('posted') || '';
if ( $posted eq 'new' ) {
- $c->forward('/auth/check_csrf_token');
+ $c->forward('update_contact');
+ } elsif ( $posted eq 'update' ) {
+ $c->forward('confirm_contacts');
+ } elsif ( $posted eq 'body' ) {
+ $c->forward('update_body', [ $c->stash->{body}, _('Values updated') ]);
+ }
+}
- my %errors;
+sub update_contact : Private {
+ my ( $self, $c ) = @_;
- my $category = $self->trim( $c->get_param('category') );
- $errors{category} = _("Please choose a category") unless $category;
- $errors{note} = _('Please enter a message') unless $c->get_param('note');
+ my $editor = $c->forward('/admin/get_user');
+ $c->forward('/auth/check_csrf_token');
- my $contact = $c->model('DB::Contact')->find_or_new(
- {
- body_id => $c->stash->{body_id},
- category => $category,
- }
- );
+ my %errors;
- my $email = $c->get_param('email');
- $email =~ s/\s+//g;
- my $send_method = $c->get_param('send_method') || $contact->send_method || $contact->body->send_method || "";
- unless ( $send_method eq 'Open311' ) {
- $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED';
- }
+ my $current_category = $c->get_param('current_category') || '';
+ my $current_contact = $c->model('DB::Contact')->find({
+ body_id => $c->stash->{body_id},
+ category => $current_category,
+ });
+ $c->stash->{current_contact} = $current_contact;
- $contact->email( $email );
- $contact->state( $c->get_param('state') );
- $contact->non_public( $c->get_param('non_public') ? 1 : 0 );
- $contact->note( $c->get_param('note') );
- $contact->whenedited( \'current_timestamp' );
- $contact->editor( $editor );
- $contact->endpoint( $c->get_param('endpoint') );
- $contact->jurisdiction( $c->get_param('jurisdiction') );
- $contact->api_key( $c->get_param('api_key') );
- $contact->send_method( $c->get_param('send_method') );
-
- # Set flags in extra to the appropriate values
- if ( $c->get_param('photo_required') ) {
- $contact->set_extra_metadata_if_undefined( photo_required => 1 );
- }
- else {
- $contact->unset_extra_metadata( 'photo_required' );
- }
- if ( $c->get_param('inspection_required') ) {
- $contact->set_extra_metadata( inspection_required => 1 );
- }
- else {
- $contact->unset_extra_metadata( 'inspection_required' );
- }
- if ( $c->get_param('reputation_threshold') ) {
- $contact->set_extra_metadata( reputation_threshold => int($c->get_param('reputation_threshold')) );
+ my $category = $self->trim( $c->get_param('category') );
+ $errors{category} = _("Please choose a category") unless $category;
+ $errors{note} = _('Please enter a message') unless $c->get_param('note') || FixMyStreet->config('STAGING_SITE');
+
+ my $contact = $c->model('DB::Contact')->find_or_new(
+ {
+ body_id => $c->stash->{body_id},
+ category => $category,
}
- if ( my $group = $c->get_param('group') ) {
- $contact->set_extra_metadata( group => $group );
- } else {
+ );
+ if ($current_contact && $contact->id && $contact->id != $current_contact->id) {
+ $errors{category} = _('You cannot rename a category to an existing category');
+ } elsif ($current_contact && !$contact->id) {
+ # Changed name
+ $contact = $current_contact;
+ $c->model('DB::Problem')->to_body($c->stash->{body_id})->search({ category => $current_category })->update({ category => $category });
+ $contact->category($category);
+ }
+
+ my $email = $c->get_param('email');
+ $email =~ s/\s+//g;
+ my $send_method = $c->get_param('send_method') || $contact->body->send_method || "";
+ my $email_unchanged = $contact->email && $email && $contact->email eq $email;
+ unless ( $send_method eq 'Open311' || $email_unchanged ) {
+ $errors{email} = _('Please enter a valid email') unless is_valid_email_list($email) || $email eq 'REFUSED';
+ }
+
+ $contact->email( $email );
+ $contact->state( $c->get_param('state') );
+ $contact->non_public( $c->get_param('non_public') ? 1 : 0 );
+ $contact->note( $c->get_param('note') );
+ $contact->whenedited( \'current_timestamp' );
+ $contact->editor( $editor );
+ $contact->endpoint( $c->get_param('endpoint') );
+ $contact->jurisdiction( $c->get_param('jurisdiction') );
+ $contact->api_key( $c->get_param('api_key') );
+ $contact->send_method( $c->get_param('send_method') );
+
+ # Set flags in extra to the appropriate values
+ if ( $c->get_param('photo_required') ) {
+ $contact->set_extra_metadata_if_undefined( photo_required => 1 );
+ } else {
+ $contact->unset_extra_metadata( 'photo_required' );
+ }
+ if ( $c->get_param('open311_protect') ) {
+ $contact->set_extra_metadata( open311_protect => 1 );
+ } else {
+ $contact->unset_extra_metadata( 'open311_protect' );
+ }
+ if ( my @group = $c->get_param_list('group') ) {
+ @group = grep { $_ } @group;
+ if (scalar @group == 0) {
$contact->unset_extra_metadata( 'group' );
+ } else {
+ $contact->set_extra_metadata( group => \@group );
}
+ } else {
+ $contact->unset_extra_metadata( 'group' );
+ }
+
+ $c->forward('/admin/update_extra_fields', [ $contact ]);
+ $c->forward('contact_cobrand_extra_fields', [ $contact, \%errors ]);
+
+ # Special form disabling form
+ if ($c->get_param('disable')) {
+ my $msg = $c->get_param('disable_message');
+ $msg = FixMyStreet::Template::sanitize($msg);
+ $errors{category} = _('Please enter a message') unless $msg;
+ my $meta = {
+ code => '_fms_disable_',
+ variable => 'false',
+ protected => 'true',
+ disable_form => 'true',
+ description => $msg,
+ };
+ $contact->update_extra_field($meta);
+ } else {
+ $contact->remove_extra_field('_fms_disable_');
+ }
+
+ if ( %errors ) {
+ $c->stash->{updated} = _('Please correct the errors below');
+ $c->stash->{contact} = $contact;
+ $c->stash->{errors} = \%errors;
+ } elsif ( $contact->in_storage ) {
+ $c->stash->{updated} = _('Values updated');
+ $c->forward('/admin/log_edit', [ $contact->id, 'category', 'edit' ]);
+ # NB: History is automatically stored by a trigger in the database
+ $contact->update;
+ } else {
+ $c->stash->{updated} = _('New category contact added');
+ $contact->insert;
+ $c->forward('/admin/log_edit', [ $contact->id, 'category', 'add' ]);
+ }
+ unless ( %errors ) {
+ $c->stash->{translation_col} = 'category';
+ $c->stash->{object} = $contact;
+ $c->forward('update_translations');
+ }
- $c->forward('/admin/update_extra_fields', [ $contact ]);
- $c->forward('contact_cobrand_extra_fields', [ $contact ]);
+}
- if ( %errors ) {
- $c->stash->{updated} = _('Please correct the errors below');
- $c->stash->{contact} = $contact;
- $c->stash->{errors} = \%errors;
- } elsif ( $contact->in_storage ) {
- $c->stash->{updated} = _('Values updated');
+sub confirm_contacts : Private {
+ my ( $self, $c ) = @_;
- # NB: History is automatically stored by a trigger in the database
- $contact->update;
- } else {
- $c->stash->{updated} = _('New category contact added');
- $contact->insert;
+ $c->forward('/auth/check_csrf_token');
+
+ my @categories = $c->get_param_list('confirmed');
+
+ my $contacts = $c->model('DB::Contact')->search(
+ {
+ body_id => $c->stash->{body_id},
+ category => { -in => \@categories },
}
+ );
- unless ( %errors ) {
- $c->stash->{translation_col} = 'category';
- $c->stash->{object} = $contact;
- $c->forward('update_translations');
+ my $editor = $c->forward('/admin/get_user');
+ $contacts->update(
+ {
+ state => 'confirmed',
+ whenedited => \'current_timestamp',
+ note => 'Confirmed',
+ editor => $editor,
}
+ );
- } elsif ( $posted eq 'update' ) {
- $c->forward('/auth/check_csrf_token');
+ $c->forward('/admin/log_edit', [ $c->stash->{body_id}, 'body', 'edit' ]);
+ $c->stash->{updated} = _('Values updated');
+}
- my @categories = $c->get_param_list('confirmed');
+sub update_body : Private {
+ my ($self, $c, $body, $msg) = @_;
- my $contacts = $c->model('DB::Contact')->search(
- {
- body_id => $c->stash->{body_id},
- category => { -in => \@categories },
- }
- );
-
- $contacts->update(
- {
- state => 'confirmed',
- whenedited => \'current_timestamp',
- note => 'Confirmed',
- editor => $editor,
- }
- );
+ $c->forward('check_for_super_user');
+ $c->forward('/auth/check_csrf_token');
- $c->stash->{updated} = _('Values updated');
- } elsif ( $posted eq 'body' ) {
- $c->forward('check_for_super_user');
- $c->forward('/auth/check_csrf_token');
-
- my $values = $c->forward( 'body_params' );
- unless ( keys %{$c->stash->{body_errors}} ) {
- $c->stash->{body}->update( $values->{params} );
- if ($values->{extras}) {
- $c->stash->{body}->set_extra_metadata( $_ => $values->{extras}->{$_} )
- for keys %{$values->{extras}};
- $c->stash->{body}->update;
- }
- my @current = $c->stash->{body}->body_areas->all;
- my %current = map { $_->area_id => 1 } @current;
- my @area_ids = $c->get_param_list('area_ids');
- foreach (@area_ids) {
- $c->model('DB::BodyArea')->find_or_create( { body => $c->stash->{body}, area_id => $_ } );
- delete $current{$_};
- }
- # Remove any others
- $c->stash->{body}->body_areas->search( { area_id => [ keys %current ] } )->delete;
+ my $values = $c->forward('body_params');
+ return if %{$c->stash->{body_errors}};
- $c->stash->{translation_col} = 'name';
- $c->stash->{object} = $c->stash->{body};
- $c->forward('update_translations');
+ if ($body) {
+ $body->update( $values->{params} );
+ $c->forward('/admin/log_edit', [ $body->id, 'body', 'edit' ]);
+ } else {
+ $body = $c->model('DB::Body')->create( $values->{params} );
+ $c->forward('/admin/log_edit', [ $body->id, 'body', 'add' ]);
+ }
- $c->stash->{updated} = _('Values updated');
- }
+ if ($values->{extras}) {
+ $body->set_extra_metadata( $_ => $values->{extras}->{$_} )
+ for keys %{$values->{extras}};
+ $body->update;
}
+ my @current = $body->body_areas->all;
+ my %current = map { $_->area_id => 1 } @current;
+ my @area_ids = $c->get_param_list('area_ids');
+ foreach (@area_ids) {
+ $c->model('DB::BodyArea')->find_or_create( { body => $body, area_id => $_ } );
+ delete $current{$_};
+ }
+ # Remove any others
+ $body->body_areas->search( { area_id => [ keys %current ] } )->delete;
+
+ $c->stash->{translation_col} = 'name';
+ $c->stash->{object} = $body;
+ $c->forward('update_translations');
+
+ $c->stash->{updated} = $msg;
}
sub body_params : Private {
@@ -375,9 +417,13 @@ sub body_params : Private {
);
my %params = map { $_ => $c->get_param($_) || $defaults{$_} } keys %defaults;
$c->forward('check_body_params', [ \%params ]);
+
my @extras = qw/fetch_all_problems/;
+ my $cobrand_extras = $c->cobrand->call_hook('body_extra_fields');
+ push @extras, @$cobrand_extras if $cobrand_extras;
+
%defaults = map { $_ => '' } @extras;
- my %extras = map { $_ => $c->get_param($_) || $defaults{$_} } @extras;
+ my %extras = map { $_ => $c->get_param("extra[$_]") || $defaults{$_} } @extras;
return { params => \%params, extras => \%extras };
}
@@ -392,12 +438,13 @@ sub check_body_params : Private {
}
sub contact_cobrand_extra_fields : Private {
- my ( $self, $c, $contact ) = @_;
+ my ( $self, $c, $contact, $errors ) = @_;
my $extra_fields = $c->cobrand->call_hook('contact_extra_fields');
foreach ( @$extra_fields ) {
$contact->set_extra_metadata( $_ => $c->get_param("extra[$_]") );
}
+ $c->cobrand->call_hook(contact_extra_fields_validation => $contact, $errors);
}
sub fetch_translations : Private {
diff --git a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
index ed9b40fd0..6c1a25e5a 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/DefectTypes.pm
@@ -76,7 +76,7 @@ sub edit : Path : Args(2) {
my @new_contact_ids = $c->get_param_list('categories');
@new_contact_ids = @{ mySociety::ArrayUtils::intersection(\@live_contact_ids, \@new_contact_ids) };
$defect_type->contact_defect_types->search({
- contact_id => { '!=' => \@new_contact_ids },
+ contact_id => { -not_in => \@new_contact_ids },
})->delete;
foreach my $contact_id (@new_contact_ids) {
$defect_type->contact_defect_types->find_or_create({
diff --git a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm b/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm
deleted file mode 100644
index 0026acb9c..000000000
--- a/perllib/FixMyStreet/App/Controller/Admin/ExorDefects.pm
+++ /dev/null
@@ -1,81 +0,0 @@
-package FixMyStreet::App::Controller::Admin::ExorDefects;
-use Moose;
-use namespace::autoclean;
-
-use DateTime;
-use Try::Tiny;
-use FixMyStreet::Integrations::ExorRDI;
-use FixMyStreet::DateRange;
-
-BEGIN { extends 'Catalyst::Controller'; }
-
-
-sub index : Path : Args(0) {
- my ( $self, $c ) = @_;
-
- foreach (qw(error_message start_date end_date user_id)) {
- if ( defined $c->flash->{$_} ) {
- $c->stash->{$_} = $c->flash->{$_};
- }
- }
-
- my @inspectors = $c->cobrand->users->search({
- 'user_body_permissions.permission_type' => 'report_inspect'
- }, {
- join => 'user_body_permissions',
- distinct => 1,
- }
- )->all;
- $c->stash->{inspectors} = \@inspectors;
-
- # Default start/end date is today
- my $now = DateTime->now( time_zone =>
- FixMyStreet->time_zone || FixMyStreet->local_time_zone );
- $c->stash->{start_date} ||= $now;
- $c->stash->{end_date} ||= $now;
-
-}
-
-sub download : Path('download') : Args(0) {
- my ( $self, $c ) = @_;
-
- if ( !$c->cobrand->can('exor_rdi_link_id') ) {
- # This only works on the Oxfordshire cobrand currently.
- $c->detach( '/page_error_404_not_found', [] );
- }
-
- my $range = FixMyStreet::DateRange->new(
- start_date => $c->get_param('start_date'),
- end_date => $c->get_param('end_date'),
- parser => DateTime::Format::Strptime->new( pattern => '%Y-%m-%d' ),
- );
-
- my $params = {
- start_date => $range->start,
- inspection_date => $range->start,
- end_date => $range->end,
- user => $c->get_param('user_id'),
- mark_as_processed => 0,
- };
- my $rdi = FixMyStreet::Integrations::ExorRDI->new($params);
-
- try {
- my $out = $rdi->construct;
- $c->res->content_type('text/csv; charset=utf-8');
- $c->res->header('content-disposition' => "attachment; filename=" . $rdi->filename);
- $c->res->body( $out );
- } catch {
- die $_ unless $_ =~ /FixMyStreet::Integrations::ExorRDI::Error/;
- if ($params->{user}) {
- $c->flash->{error_message} = _("No inspections by that inspector in the selected date range.");
- } else {
- $c->flash->{error_message} = _("No inspections in the selected date range.");
- }
- $c->flash->{start_date} = $params->{start_date};
- $c->flash->{end_date} = $params->{end_date};
- $c->flash->{user_id} = $params->{user};
- $c->res->redirect( $c->uri_for( '' ) );
- };
-}
-
-1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm
new file mode 100644
index 000000000..9e3bdc33e
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm
@@ -0,0 +1,99 @@
+package FixMyStreet::App::Controller::Admin::ManifestTheme;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::App::Form::ManifestTheme;
+
+sub auto :Private {
+ my ($self, $c) = @_;
+
+ if ( $c->cobrand->moniker eq 'fixmystreet' ) {
+ $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({}), show_all => 1);
+ } else {
+ $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({ cobrand => $c->cobrand->moniker }));
+ }
+}
+
+sub index :Path :Args(0) {
+ my ( $self, $c ) = @_;
+
+ unless ( $c->stash->{show_all} ) {
+ if ( $c->stash->{rs}->count ) {
+ $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ]));
+ } else {
+ $c->res->redirect($c->uri_for($self->action_for('create')));
+ }
+ $c->detach;
+ }
+}
+
+sub item :PathPart('admin/manifesttheme') :Chained :CaptureArgs(1) {
+ my ($self, $c, $cobrand) = @_;
+
+ my $obj = $c->stash->{rs}->find({ cobrand => $cobrand })
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
+
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+
+ my $form = $self->form($c, $c->stash->{obj});
+
+ # We need to do this after form processing, in case a form POST has deleted
+ # an icon.
+ $c->stash->{editing_manifest_theme} = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]);
+
+ return $form;
+}
+
+
+sub create :Local :Args(0) {
+ my ($self, $c) = @_;
+
+ unless ( $c->stash->{show_all} || $c->stash->{rs}->count == 0) {
+ $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ]));
+ $c->detach;
+ }
+
+ my $theme = $c->stash->{rs}->new_result({});
+ return $self->form($c, $theme);
+}
+
+sub form {
+ my ($self, $c, $theme) = @_;
+
+ if ($c->get_param('delete_theme')) {
+ $c->forward('_delete_all_manifest_icons');
+ $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]);
+ $theme->delete;
+ $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', 'delete' ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+ $c->detach;
+ }
+
+ my $action = $theme->in_storage ? 'edit' : 'add';
+ my $form = FixMyStreet::App::Form::ManifestTheme->new( cobrand => $c->cobrand->moniker );
+ $c->stash(template => 'admin/manifesttheme/form.html', form => $form);
+ my $params = $c->req->params;
+ $params->{icon} = $c->req->upload('icon') if $params->{icon};
+ $form->process(item => $theme, params => $params);
+ return unless $form->validated;
+
+ $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', $action ]);
+ $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+}
+
+sub _delete_all_manifest_icons :Private {
+ my ($self, $c) = @_;
+
+ my $theme = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]);
+ foreach my $icon ( @{ $theme->{icons} } ) {
+ unlink FixMyStreet->path_to('web', $icon->{src});
+ }
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Reports.pm b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm
new file mode 100644
index 000000000..7300fe676
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Reports.pm
@@ -0,0 +1,523 @@
+package FixMyStreet::App::Controller::Admin::Reports;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use List::MoreUtils 'uniq';
+use FixMyStreet::SMS;
+use Utils;
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Admin::Reports - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Admin pages
+
+=head1 METHODS
+
+=cut
+
+sub index : Path {
+ my ( $self, $c ) = @_;
+
+ $c->stash->{edit_body_contacts} = 1
+ if grep { $_ eq 'body' } keys %{$c->stash->{allowed_pages}};
+
+ my $query = {};
+ if ( $c->cobrand->moniker eq 'zurich' ) {
+ my $type = $c->stash->{admin_type};
+ my $body = $c->stash->{body};
+ if ( $type eq 'dm' ) {
+ my @children = map { $_->id } $body->bodies->all;
+ my @all = (@children, $body->id);
+ $query = { bodies_str => \@all };
+ } elsif ( $type eq 'sdm' ) {
+ $query = { bodies_str => $body->id };
+ }
+ }
+
+ my $order = $c->get_param('o') || 'id';
+ my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1;
+ $c->stash->{order} = $order;
+ $c->stash->{dir} = $dir;
+ $order = $dir ? { -desc => "me.$order" } : "me.$order";
+
+ my $p_page = $c->get_param('p') || 1;
+ my $u_page = $c->get_param('u') || 1;
+
+ return if $c->cobrand->call_hook(report_search_query => $query, $p_page, $u_page, $order);
+
+ if (my $search = $c->get_param('search')) {
+ $search = $self->trim($search);
+
+ # In case an email address, wrapped in <...>
+ if ($search =~ /^<(.*)>$/) {
+ my $possible_email = $1;
+ my $parsed = FixMyStreet::SMS->parse_username($possible_email);
+ $search = $possible_email if $parsed->{email};
+ }
+
+ $c->stash->{searched} = $search;
+
+ my $search_n = 0;
+ $search_n = int($search) if $search =~ /^\d+$/;
+
+ my $like_search = "%$search%";
+
+ my $parsed = FixMyStreet::SMS->parse_username($search);
+ my $valid_phone = $parsed->{phone};
+ my $valid_email = $parsed->{email};
+
+ if ($valid_email) {
+ $query->{'-or'} = [
+ 'user.email' => { ilike => $like_search },
+ ];
+ } elsif ($valid_phone) {
+ $query->{'-or'} = [
+ 'user.phone' => { ilike => $like_search },
+ ];
+ } elsif ($search =~ /^id:(\d+)$/) {
+ $query->{'-or'} = [
+ 'me.id' => int($1),
+ ];
+ } elsif ($search =~ /^area:(\d+)$/) {
+ $query->{'-or'} = [
+ 'me.areas' => { like => "%,$1,%" }
+ ];
+ } elsif ($search =~ /^ref:(\d+)$/) {
+ $query->{'-or'} = [
+ 'me.external_id' => { like => "%$1%" }
+ ];
+ } else {
+ $query->{'-or'} = [
+ 'me.id' => $search_n,
+ 'user.email' => { ilike => $like_search },
+ 'user.phone' => { ilike => $like_search },
+ 'me.external_id' => { ilike => $like_search },
+ 'me.name' => { ilike => $like_search },
+ 'me.title' => { ilike => $like_search },
+ detail => { ilike => $like_search },
+ bodies_str => { like => $like_search },
+ cobrand_data => { like => $like_search },
+ ];
+ }
+
+ my $problems = $c->cobrand->problems->search(
+ $query,
+ {
+ join => 'user',
+ '+columns' => 'user.email',
+ rows => 50,
+ order_by => $order,
+ }
+ )->page( $p_page );
+
+ $c->stash->{problems} = [ $problems->all ];
+ $c->stash->{problems_pager} = $problems->pager;
+
+ if ($valid_email) {
+ $query = [
+ 'user.email' => { ilike => $like_search },
+ ];
+ } elsif ($valid_phone) {
+ $query = [
+ 'user.phone' => { ilike => $like_search },
+ ];
+ } elsif ($search =~ /^id:(\d+)$/) {
+ $query = [
+ 'me.id' => int($1),
+ 'me.problem_id' => int($1),
+ ];
+ } elsif ($search =~ /^area:(\d+)$/) {
+ $query = [];
+ } else {
+ $query = [
+ 'me.id' => $search_n,
+ 'problem.id' => $search_n,
+ 'user.email' => { ilike => $like_search },
+ 'user.phone' => { ilike => $like_search },
+ 'me.name' => { ilike => $like_search },
+ text => { ilike => $like_search },
+ 'me.cobrand_data' => { ilike => $like_search },
+ ];
+ }
+
+ if (@$query) {
+ my $updates = $c->cobrand->updates->search(
+ {
+ -or => $query,
+ },
+ {
+ '+columns' => ['user.email'],
+ join => 'user',
+ prefetch => [qw/problem/],
+ rows => 50,
+ order_by => { -desc => 'me.id' }
+ }
+ )->page( $u_page );
+ $c->stash->{updates} = [ $updates->all ];
+ $c->stash->{updates_pager} = $updates->pager;
+ }
+
+ } else {
+
+ my $problems = $c->cobrand->problems->search(
+ $query,
+ { order_by => $order, rows => 50 }
+ )->page( $p_page );
+ $c->stash->{problems} = [ $problems->all ];
+ $c->stash->{problems_pager} = $problems->pager;
+ }
+}
+
+sub edit_display : Private {
+ my ( $self, $c ) = @_;
+
+ my $problem = $c->stash->{problem};
+
+ $c->stash->{page} = 'admin';
+ FixMyStreet::Map::display_map(
+ $c,
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ pins => $problem->used_map
+ ? [ {
+ latitude => $problem->latitude,
+ longitude => $problem->longitude,
+ colour => $c->cobrand->pin_colour($problem, 'admin'),
+ type => 'big',
+ draggable => 1,
+ } ]
+ : [],
+ print_report => 1,
+ );
+}
+
+sub edit : Path('/admin/report_edit') : Args(1) {
+ my ( $self, $c, $id ) = @_;
+
+ my $problem = $c->cobrand->problems->search( { id => $id } )->first;
+
+ $c->detach( '/page_error_404_not_found', [] )
+ unless $problem;
+
+ unless (
+ $c->cobrand->moniker eq 'zurich'
+ || $c->user->has_permission_to(report_edit => $problem->bodies_str_ids)
+ ) {
+ $c->detach( '/page_error_403_access_denied', [] );
+ }
+
+ $c->stash->{problem} = $problem;
+ if ( $problem->extra ) {
+ my @fields;
+ if ( my $fields = $problem->get_extra_fields ) {
+ for my $field ( @{$fields} ) {
+ my $name = $field->{description} ?
+ "$field->{description} ($field->{name})" :
+ "$field->{name}";
+ push @fields, { name => $name, val => $field->{value} };
+ }
+ }
+ my $extra = $problem->get_extra_metadata;
+ if ( $extra->{duplicates} ) {
+ push @fields, { name => 'Duplicates', val => join( ',', @{ $problem->get_extra_metadata('duplicates') } ) };
+ delete $extra->{duplicates};
+ }
+ for my $key ( keys %$extra ) {
+ push @fields, { name => $key, val => $extra->{$key} };
+ }
+
+ $c->stash->{extra_fields} = \@fields;
+ }
+
+ $c->forward('/auth/get_csrf_token');
+
+ $c->forward('categories_for_point');
+
+ $c->forward('alerts_for_report');
+
+ $c->forward('/admin/check_username_for_abuse', [ $problem->user ] );
+
+ $c->stash->{updates} =
+ [ $c->model('DB::Comment')
+ ->search( { problem_id => $problem->id }, { order_by => [ 'created', 'id' ] } )
+ ->all ];
+
+ if (my $rotate_photo_param = $c->forward('/admin/_get_rotate_photo_param')) {
+ $c->forward('/admin/rotate_photo', [$problem, @$rotate_photo_param]);
+ $c->detach('edit_display');
+ }
+
+ if ( $c->cobrand->moniker eq 'zurich' ) {
+ my $done = $c->cobrand->admin_report_edit();
+ $c->detach('edit_display') if $done;
+ }
+
+ if ( $c->get_param('resend') && !$c->cobrand->call_hook('disable_resend_button') ) {
+ $c->forward('/auth/check_csrf_token');
+
+ $problem->resend;
+ $problem->update();
+ $c->stash->{status_message} = _('That problem will now be resent.');
+
+ $c->forward( '/admin/log_edit', [ $id, 'problem', 'resend' ] );
+ }
+ elsif ( $c->get_param('mark_sent') ) {
+ $c->forward('/auth/check_csrf_token');
+ $problem->update({ whensent => \'current_timestamp' })->discard_changes;
+ $c->stash->{status_message} = _('That problem has been marked as sent.');
+ $c->forward( '/admin/log_edit', [ $id, 'problem', 'marked sent' ] );
+ }
+ elsif ( $c->get_param('flaguser') ) {
+ $c->forward('/admin/users/flag');
+ $c->stash->{problem}->discard_changes;
+ }
+ elsif ( $c->get_param('removeuserflag') ) {
+ $c->forward('/admin/users/flag_remove');
+ $c->stash->{problem}->discard_changes;
+ }
+ elsif ( $c->get_param('banuser') ) {
+ $c->forward('/admin/users/ban');
+ }
+ elsif ( $c->get_param('submit') ) {
+ $c->forward('/auth/check_csrf_token');
+
+ my $old_state = $problem->state;
+
+ my %columns = (
+ flagged => $c->get_param('flagged') ? 1 : 0,
+ non_public => $c->get_param('non_public') ? 1 : 0,
+ );
+ foreach (qw/state anonymous title detail name external_id external_body external_team/) {
+ $columns{$_} = $c->get_param($_);
+ }
+
+ # Look this up here for moderation line to use
+ my $remove_photo_param = $c->forward('/admin/_get_remove_photo_param');
+
+ if ($columns{title} ne $problem->title || $columns{detail} ne $problem->detail ||
+ $columns{anonymous} ne $problem->anonymous || $remove_photo_param) {
+ $problem->create_related( moderation_original_data => {
+ title => $problem->title,
+ detail => $problem->detail,
+ photo => $problem->photo,
+ anonymous => $problem->anonymous,
+ category => $problem->category,
+ $problem->extra ? (extra => $problem->extra) : (),
+ });
+ }
+
+ $problem->set_inflated_columns(\%columns);
+
+ if ($c->get_param('closed_updates')) {
+ $problem->set_extra_metadata(closed_updates => 1);
+ } else {
+ $problem->unset_extra_metadata('closed_updates');
+ }
+
+ $c->forward( '/admin/reports/edit_category', [ $problem, $problem->state ne $old_state ] );
+ $c->forward('/admin/update_user', [ $problem ]);
+
+ # Deal with photos
+ if ($remove_photo_param) {
+ $c->forward('/admin/remove_photo', [ $problem, $remove_photo_param ]);
+ }
+
+ if ($problem->state eq 'hidden' || $problem->non_public) {
+ $problem->get_photoset->delete_cached(plus_updates => 1);
+ }
+
+ if ( $problem->is_visible() and $old_state eq 'unconfirmed' ) {
+ $problem->confirmed( \'current_timestamp' );
+ }
+
+ $problem->lastupdate( \'current_timestamp' );
+ $problem->update;
+
+ if ( $problem->state ne $old_state ) {
+ $c->forward( '/admin/log_edit', [ $id, 'problem', 'state_change' ] );
+
+ my $name = $c->user->moderating_user_name;
+ my $extra = { is_superuser => 1 };
+ if ($c->user->from_body) {
+ delete $extra->{is_superuser};
+ $extra->{is_body_user} = $c->user->from_body->id;
+ }
+ my $timestamp = \'current_timestamp';
+ $problem->add_to_comments( {
+ text => $c->stash->{update_text} || '',
+ created => $timestamp,
+ confirmed => $timestamp,
+ user_id => $c->user->id,
+ name => $name,
+ mark_fixed => 0,
+ anonymous => 0,
+ state => 'confirmed',
+ problem_state => $problem->state,
+ extra => $extra
+ } );
+ }
+ $c->forward( '/admin/log_edit', [ $id, 'problem', 'edit' ] );
+
+ $c->stash->{status_message} = _('Updated!');
+
+ # do this here otherwise lastupdate and confirmed times
+ # do not display correctly
+ $problem->discard_changes;
+ }
+
+ $c->detach('edit_display');
+}
+
+=head2 edit_category
+
+Handles changing a problem's category and the complexity that comes with it.
+Returns 1 if category changed, 0 if no change.
+
+=cut
+
+sub edit_category : Private {
+ my ($self, $c, $problem, $no_comment) = @_;
+
+ if ((my $category = $c->get_param('category')) ne $problem->category) {
+ my $force_resend = $c->cobrand->call_hook('category_change_force_resend', $problem->category, $category);
+ my $disable_resend = $c->cobrand->call_hook('disable_resend');
+ my $category_old = $problem->category;
+ $problem->category($category);
+ my @contacts = grep { $_->category eq $problem->category } @{$c->stash->{contacts}};
+ my @new_body_ids = map { $_->body_id } @contacts;
+ # If the report has changed bodies (and not to a subset!) we need to resend it
+ my %old_map = map { $_ => 1 } @{$problem->bodies_str_ids};
+ if (!$disable_resend && grep !$old_map{$_}, @new_body_ids) {
+ $problem->resend;
+ }
+ # If the send methods of the old/new contacts differ we need to resend the report
+ my @new_send_methods = uniq map {
+ ( $_->body->can_be_devolved && $_->send_method ) ?
+ $_->send_method : $_->body->send_method
+ ? $_->body->send_method
+ : $c->cobrand->_fallback_body_sender()->{method};
+ } @contacts;
+ my %old_send_methods = map { $_ => 1 } split /,/, ($problem->send_method_used || "Email");
+ if (!$disable_resend && grep !$old_send_methods{$_}, @new_send_methods) {
+ $problem->resend;
+ }
+ if ($force_resend) {
+ $problem->resend;
+ }
+
+ $problem->bodies_str(join( ',', @new_body_ids ));
+ my $update_text = '*' . sprintf(_('Category changed from ‘%s’ to ‘%s’'), $category_old, $category) . '*';
+ if ($no_comment) {
+ $c->stash->{update_text} = $update_text;
+ } else {
+ $problem->add_to_comments({
+ text => $update_text,
+ created => \'current_timestamp',
+ confirmed => \'current_timestamp',
+ user_id => $c->user->id,
+ name => $c->user->from_body ? $c->user->from_body->name : $c->user->name,
+ state => 'confirmed',
+ mark_fixed => 0,
+ anonymous => 0,
+ });
+ }
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'category_change' ] );
+ return 1;
+ }
+ return 0;
+}
+
+=head2 edit_location
+
+Handles changing a problem's location and the complexity that comes with it.
+For now, we reject the new location if the new location and old locations aren't
+covered by the same body.
+
+Returns 2 if the new position (if any) is acceptable and changed,
+1 if acceptable and unchanged, undef otherwise.
+
+NB: This must be called before edit_category, as that might modify
+$problem->bodies_str.
+
+=cut
+
+sub edit_location : Private {
+ my ($self, $c, $problem) = @_;
+
+ return 1 unless $c->forward('/location/determine_location_from_coords');
+
+ my ($lat, $lon) = map { Utils::truncate_coordinate($_) } $problem->latitude, $problem->longitude;
+ if ( $c->stash->{latitude} != $lat || $c->stash->{longitude} != $lon ) {
+ # The two actions below change the stash, setting things up for e.g. a
+ # new report. But here we're only doing it in order to check the found
+ # bodies match; we don't want to overwrite the existing report data if
+ # this lookup is bad. So let's save the stash and restore it after the
+ # comparison.
+ my $safe_stash = { %{$c->stash} };
+ $c->stash->{fetch_all_areas} = 1;
+ $c->stash->{area_check_action} = 'admin';
+ $c->forward('/council/load_and_check_areas', []);
+ $c->forward('/report/new/setup_categories_and_bodies');
+ my %allowed_bodies = map { $_ => 1 } @{$problem->bodies_str_ids};
+ my @new_bodies = keys %{$c->stash->{bodies_to_list}};
+ my $bodies_match = grep { exists( $allowed_bodies{$_} ) } @new_bodies;
+ $c->stash($safe_stash);
+ return unless $bodies_match;
+ $problem->latitude($c->stash->{latitude});
+ $problem->longitude($c->stash->{longitude});
+ my $areas = $c->stash->{all_areas_mapit};
+ $problem->areas( ',' . join( ',', sort keys %$areas ) . ',' );
+ return 2;
+ }
+ return 1;
+}
+
+sub categories_for_point : Private {
+ my ($self, $c) = @_;
+
+ $c->stash->{report} = $c->stash->{problem};
+ # We have a report, stash its location
+ $c->forward('/report/new/determine_location_from_report');
+ # Look up the areas for this location
+ my $prefetched_all_areas = [ grep { $_ } split ',', $c->stash->{report}->areas ];
+ $c->forward('/around/check_location_is_acceptable', [ $prefetched_all_areas ]);
+ # As with a new report, fetch the bodies/categories
+ $c->stash->{categories_for_point} = 1;
+ $c->forward('/report/new/setup_categories_and_bodies');
+
+ # Remove the "Pick a category" option
+ shift @{$c->stash->{category_options}} if @{$c->stash->{category_options}};
+
+ $c->stash->{categories_hash} = { map { $_->category => 1 } @{$c->stash->{category_options}} };
+
+ $c->forward('/admin/triage/setup_categories');
+
+}
+
+sub alerts_for_report : Private {
+ my ($self, $c) = @_;
+
+ $c->stash->{alert_count} = $c->model('DB::Alert')->search({
+ alert_type => 'new_updates',
+ parameter => $c->stash->{report}->id,
+ confirmed => 1,
+ whendisabled => undef,
+ })->count();
+}
+
+sub trim {
+ my $self = shift;
+ my $e = shift;
+ $e =~ s/^\s+//;
+ $e =~ s/\s+$//;
+ return $e;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
index 2613f6ae0..5e2908290 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/ResponsePriorities.pm
@@ -4,98 +4,94 @@ use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use FixMyStreet::App::Form::ResponsePriority;
-sub index : Path : Args(0) {
- my ( $self, $c ) = @_;
+sub auto :Private {
+ my ($self, $c) = @_;
my $user = $c->user;
-
if ($user->is_superuser) {
- $c->forward('/admin/fetch_all_bodies');
- } elsif ( $user->from_body ) {
- $c->forward('load_user_body', [ $user->from_body->id ]);
- $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) );
- } else {
- $c->detach( '/page_error_404_not_found' );
+ $c->stash(rs => $c->model('DB::ResponsePriority')->search_rs(undef, {
+ prefetch => 'body',
+ order_by => ['body.name', 'me.name']
+ }));
+ } elsif ($user->from_body) {
+ $c->stash(rs => $user->from_body->response_priorities->search_rs(undef, {
+ order_by => 'name'
+ }));
}
}
-sub list : Path : Args(1) {
- my ($self, $c, $body_id) = @_;
-
- $c->forward('load_user_body', [ $body_id ]);
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
- my @priorities = $c->stash->{body}->response_priorities->search(
- undef,
- {
- order_by => 'name'
- }
+ if (my $body_id = $c->get_param('body_id')) {
+ $c->res->redirect($c->uri_for($self->action_for('create'), [ $body_id ]));
+ $c->detach;
+ }
+ if ($c->user->is_superuser) {
+ $c->forward('/admin/fetch_all_bodies');
+ }
+ $c->stash(
+ response_priorities => [ $c->stash->{rs}->all ],
);
-
- $c->stash->{response_priorities} = \@priorities;
}
-sub edit : Path : Args(2) {
- my ( $self, $c, $body_id, $priority_id ) = @_;
-
- $c->forward('load_user_body', [ $body_id ]);
+sub body :PathPart('admin/responsepriorities') :Chained :CaptureArgs(1) {
+ my ($self, $c, $body_id) = @_;
- my $priority;
- if ($priority_id eq 'new') {
- $priority = $c->stash->{body}->response_priorities->new({});
- }
- else {
- $priority = $c->stash->{body}->response_priorities->find( $priority_id )
- or $c->detach( '/page_error_404_not_found' );
+ my $user = $c->user;
+ if ($user->is_superuser) {
+ $c->stash->{body} = $c->model('DB::Body')->find($body_id);
+ } elsif ($user->from_body && $user->from_body->id == $body_id) {
+ $c->stash->{body} = $user->from_body;
}
- $c->forward('/admin/fetch_contacts');
- my @contacts = $priority->contacts->all;
- my @live_contacts = $c->stash->{live_contacts}->all;
- my %active_contacts = map { $_->id => 1 } @contacts;
- my @all_contacts = map { {
- id => $_->id,
- category => $_->category,
- active => $active_contacts{$_->id},
- } } @live_contacts;
- $c->stash->{contacts} = \@all_contacts;
-
- if ($c->req->method eq 'POST') {
- $priority->deleted( $c->get_param('deleted') ? 1 : 0 );
- $priority->name( $c->get_param('name') );
- $priority->description( $c->get_param('description') );
- $priority->external_id( $c->get_param('external_id') );
- $priority->is_default( $c->get_param('is_default') ? 1 : 0 );
- $priority->update_or_insert;
-
- my @live_contact_ids = map { $_->id } @live_contacts;
- my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
- $priority->contact_response_priorities->search({
- contact_id => { '!=' => \@new_contact_ids },
- })->delete;
- foreach my $contact_id (@new_contact_ids) {
- $priority->contact_response_priorities->find_or_create({
- contact_id => $contact_id,
- });
- }
-
- $c->res->redirect( $c->uri_for( '', $c->stash->{body}->id ) );
- }
+ $c->detach( '/page_error_404_not_found' ) unless $c->stash->{body};
+}
- $c->stash->{response_priority} = $priority;
+sub create :Chained('body') :Args(0) {
+ my ($self, $c) = @_;
+
+ my $priority = $c->stash->{rs}->new_result({ body => $c->stash->{body} });
+ return $self->form($c, $priority);
}
-sub load_user_body : Private {
- my ($self, $c, $body_id) = @_;
+sub item :PathPart('') :Chained('body') :CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
- my $has_permission = $c->user->has_body_permission_to('responsepriority_edit', $body_id);
+ my $obj = $c->stash->{rs}->find($id)
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
- unless ( $has_permission ) {
- $c->detach( '/page_error_404_not_found' );
- }
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+ return $self->form($c, $c->stash->{obj});
+}
+
+sub form {
+ my ($self, $c, $priority) = @_;
- $c->stash->{body} = $c->model('DB::Body')->find($body_id)
- or $c->detach( '/page_error_404_not_found' );
+ # Otherwise, the form includes contacts for *all* bodies
+ $c->forward('/admin/fetch_contacts');
+ my @all_contacts = map {
+ { value => $_->id, label => $_->category }
+ } $c->stash->{live_contacts}->all;
+
+ my $opts = {
+ field_list => [
+ '+contacts' => { options => \@all_contacts },
+ ],
+ body_id => $c->stash->{body}->id,
+ };
+
+ my $form = FixMyStreet::App::Form::ResponsePriority->new(%$opts);
+ $c->stash(template => 'admin/responsepriorities/edit.html', form => $form);
+ $form->process(item => $priority, params => $c->req->params);
+ return unless $form->validated;
+
+ $c->response->redirect($c->uri_for($self->action_for('index')));
}
__PACKAGE__->meta->make_immutable;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Roles.pm b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm
new file mode 100644
index 000000000..279ee695c
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Roles.pm
@@ -0,0 +1,102 @@
+package FixMyStreet::App::Controller::Admin::Roles;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+use FixMyStreet::App::Form::Role;
+
+sub auto :Private {
+ my ($self, $c) = @_;
+
+ my $user = $c->user;
+ if ($user->is_superuser) {
+ $c->stash(rs => $c->model('DB::Role')->search_rs({}, {
+ prefetch => 'body',
+ order_by => ['body.name', 'me.name']
+ }));
+ } elsif ($user->from_body) {
+ $c->stash(rs => $user->from_body->roles->search_rs({}, { order_by => 'name' }));
+ }
+}
+
+sub index :Path :Args(0) {
+ my ($self, $c) = @_;
+
+ my $p = $c->cobrand->available_permissions;
+ my %labels;
+ foreach my $group (sort keys %$p) {
+ my $group_vals = $p->{$group};
+ foreach (sort keys %$group_vals) {
+ $labels{$_} = $group_vals->{$_};
+ }
+ }
+
+ $c->stash(
+ roles => [ $c->stash->{rs}->all ],
+ labels => \%labels,
+ );
+}
+
+sub create :Local :Args(0) {
+ my ($self, $c) = @_;
+
+ my $role = $c->stash->{rs}->new_result({});
+ return $self->form($c, $role);
+}
+
+sub item :PathPart('admin/roles') :Chained :CaptureArgs(1) {
+ my ($self, $c, $id) = @_;
+
+ my $obj = $c->stash->{rs}->find($id)
+ or $c->detach('/page_error_404_not_found', []);
+ $c->stash(obj => $obj);
+}
+
+sub edit :PathPart('') :Chained('item') :Args(0) {
+ my ($self, $c) = @_;
+ return $self->form($c, $c->stash->{obj});
+}
+
+sub form {
+ my ($self, $c, $role) = @_;
+
+ if ($c->get_param('delete_role')) {
+ $role->delete;
+ $c->forward('/admin/log_edit', [ $role->id, 'role', 'delete' ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+ $c->detach;
+ }
+
+ my $perms = [];
+ my $p = $c->cobrand->available_permissions;
+ foreach my $group (sort keys %$p) {
+ my $group_vals = $p->{$group};
+ my @foo;
+ foreach (sort keys %$group_vals) {
+ push @foo, { value => $_, label => $group_vals->{$_} };
+ }
+ push @$perms, { group => $group, options => \@foo };
+ }
+ my $opts = {
+ field_list => [
+ '+permissions' => { options => $perms },
+ ],
+ };
+
+ if (!$c->user->is_superuser && $c->user->from_body) {
+ push @{$opts->{field_list}}, '+body', { inactive => 1 };
+ $opts->{body_id} = $c->user->from_body->id;
+ }
+
+ my $action = $role->in_storage ? 'edit' : 'add';
+ my $form = FixMyStreet::App::Form::Role->new(%$opts);
+ $c->stash(template => 'admin/roles/form.html', form => $form);
+ $form->process(item => $role, params => $c->req->params);
+ return unless $form->validated;
+
+ $c->forward('/admin/log_edit', [ $role->id, 'role', $action ]);
+ $c->response->redirect($c->uri_for($self->action_for('index')));
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
index 5f82094d6..03b529a55 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Stats.pm
@@ -7,6 +7,52 @@ BEGIN { extends 'Catalyst::Controller'; }
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
return $c->cobrand->admin_stats() if $c->cobrand->moniker eq 'zurich';
+ $c->forward('gather');
+}
+
+sub gather : Private {
+ my ($self, $c) = @_;
+
+ $c->forward('state'); # Problem/update stats used on that page
+ $c->forward('/admin/fetch_all_bodies'); # For body stat
+
+ my $alerts = $c->model('DB::Alert')->summary_report_alerts( $c->cobrand->restriction );
+
+ my %alert_counts =
+ map { $_->confirmed => $_->get_column('confirmed_count') } $alerts->all;
+
+ $alert_counts{0} ||= 0;
+ $alert_counts{1} ||= 0;
+
+ $c->stash->{alerts} = \%alert_counts;
+
+ my $contacts = $c->model('DB::Contact')->summary_count();
+
+ my %contact_counts =
+ map { $_->state => $_->get_column('state_count') } $contacts->all;
+
+ $contact_counts{confirmed} ||= 0;
+ $contact_counts{unconfirmed} ||= 0;
+ $contact_counts{total} = $contact_counts{confirmed} + $contact_counts{unconfirmed};
+
+ $c->stash->{contacts} = \%contact_counts;
+
+ my $questionnaires = $c->model('DB::Questionnaire')->summary_count( $c->cobrand->restriction );
+
+ my %questionnaire_counts = map {
+ $_->get_column('answered') => $_->get_column('questionnaire_count')
+ } $questionnaires->all;
+ $questionnaire_counts{1} ||= 0;
+ $questionnaire_counts{0} ||= 0;
+
+ $questionnaire_counts{total} =
+ $questionnaire_counts{0} + $questionnaire_counts{1};
+ $c->stash->{questionnaires_pc} =
+ $questionnaire_counts{total}
+ ? sprintf( '%.1f',
+ $questionnaire_counts{1} / $questionnaire_counts{total} * 100 )
+ : _('n/a');
+ $c->stash->{questionnaires} = \%questionnaire_counts;
}
sub state : Local : Args(0) {
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Templates.pm b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm
new file mode 100644
index 000000000..efff1b488
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Templates.pm
@@ -0,0 +1,181 @@
+package FixMyStreet::App::Controller::Admin::Templates;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Admin::Templates - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Admin pages for response templates
+
+=head1 METHODS
+
+=cut
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $user = $c->user;
+
+ if ($user->is_superuser) {
+ $c->forward('/admin/fetch_all_bodies');
+ } elsif ( $user->from_body ) {
+ $c->forward('load_template_body', [ $user->from_body->id ]);
+ $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) );
+ } else {
+ $c->detach( '/page_error_404_not_found', [] );
+ }
+}
+
+sub view : Path : Args(1) {
+ my ($self, $c, $body_id) = @_;
+
+ $c->forward('load_template_body', [ $body_id ]);
+
+ my @templates = $c->stash->{body}->response_templates->search(
+ undef,
+ {
+ order_by => 'title'
+ }
+ );
+
+ $c->stash->{response_templates} = \@templates;
+}
+
+sub edit : Path : Args(2) {
+ my ( $self, $c, $body_id, $template_id ) = @_;
+
+ $c->forward('load_template_body', [ $body_id ]);
+
+ my $template;
+ if ($template_id eq 'new') {
+ $template = $c->stash->{body}->response_templates->new({});
+ }
+ else {
+ $template = $c->stash->{body}->response_templates->find( $template_id )
+ or $c->detach( '/page_error_404_not_found', [] );
+ }
+
+ $c->forward('/admin/fetch_contacts');
+ my @contacts = $template->contacts->all;
+ my @live_contacts = $c->stash->{live_contacts}->all;
+ my %active_contacts = map { $_->id => 1 } @contacts;
+ my @all_contacts = map { {
+ id => $_->id,
+ category => $_->category_display,
+ active => $active_contacts{$_->id},
+ email => $_->email,
+ group => $_->get_extra_metadata('group') // '',
+ } } @live_contacts;
+ $c->stash->{contacts} = \@all_contacts;
+ $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups;
+
+ # bare block to use 'last' if form is invalid.
+ if ($c->req->method eq 'POST') { {
+ if ($c->get_param('delete_template') && $c->get_param('delete_template') eq _("Delete template")) {
+ $template->contact_response_templates->delete_all;
+ $template->delete;
+ $c->forward('/admin/log_edit', [ $template->id, 'template', 'delete' ]);
+ } else {
+ my @live_contact_ids = map { $_->id } @live_contacts;
+ my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
+ my %new_contacts = map { $_ => 1 } @new_contact_ids;
+ for my $contact (@all_contacts) {
+ $contact->{active} = $new_contacts{$contact->{id}};
+ }
+
+ $template->title( $c->get_param('title') );
+ my $query = { title => $template->title };
+ if ($template->in_storage) {
+ $query->{id} = { '!=', $template->id };
+ }
+ if ($c->stash->{body}->response_templates->search($query)->count) {
+ $c->stash->{errors} ||= {};
+ $c->stash->{errors}->{title} = _("There is already a template with that title.");
+ }
+
+ $template->text( $c->get_param('text') );
+ $template->state( $c->get_param('state') );
+ $template->external_status_code( $c->get_param('external_status_code') );
+
+ if ( $template->state && $template->external_status_code ) {
+ $c->stash->{errors} ||= {};
+ $c->stash->{errors}->{state} = _("State and external status code cannot be used simultaneously.");
+ $c->stash->{errors}->{external_status_code} = _("State and external status code cannot be used simultaneously.");
+ }
+
+ $template->auto_response( $c->get_param('auto_response') && ( $template->state || $template->external_status_code ) ? 1 : 0 );
+ if ($template->auto_response) {
+ my @check_contact_ids = @new_contact_ids;
+ # If the new template has not specific categories (i.e. it
+ # applies to all categories) then we need to check each of those
+ # category ids for existing auto-response templates.
+ if (!scalar @check_contact_ids) {
+ @check_contact_ids = @live_contact_ids;
+ }
+ my $query = {
+ 'auto_response' => 1,
+ 'contact.id' => [ @check_contact_ids, undef ],
+ -or => {
+ $template->state ? ('me.state' => $template->state) : (),
+ $template->external_status_code ? ('me.external_status_code' => $template->external_status_code) : (),
+ },
+ };
+ if ($template->in_storage) {
+ $query->{'me.id'} = { '!=', $template->id };
+ }
+ if ($c->stash->{body}->response_templates->search($query, {
+ join => { 'contact_response_templates' => 'contact' },
+ })->count) {
+ $c->stash->{errors} ||= {};
+ $c->stash->{errors}->{auto_response} = _("There is already an auto-response template for this category/state.");
+ }
+ }
+
+ last if $c->stash->{errors};
+
+ $template->update_or_insert;
+ $template->contact_response_templates->search({
+ contact_id => { -not_in => \@new_contact_ids },
+ })->delete;
+ foreach my $contact_id (@new_contact_ids) {
+ $template->contact_response_templates->find_or_create({
+ contact_id => $contact_id,
+ });
+ }
+ my $action = $template_id eq 'new' ? 'add' : 'edit';
+ $c->forward('/admin/log_edit', [ $template->id, 'template', $action ]);
+ }
+
+ $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->stash->{body}->id ) );
+ } }
+
+ $c->stash->{response_template} = $template;
+}
+
+sub load_template_body : Private {
+ my ($self, $c, $body_id) = @_;
+
+ my $zurich_user = $c->user->from_body && $c->cobrand->moniker eq 'zurich';
+ my $has_permission = $c->user->has_body_permission_to('template_edit', $body_id);
+
+ unless ( $zurich_user || $has_permission ) {
+ $c->detach( '/page_error_404_not_found', [] );
+ }
+
+ # Regular users can only view their own body's templates
+ if ( !$c->user->is_superuser && $body_id ne $c->user->from_body->id ) {
+ $c->res->redirect( $c->uri_for_action( '/admin/templates/view', $c->user->from_body->id ) );
+ }
+
+ $c->stash->{body} = $c->model('DB::Body')->find($body_id)
+ or $c->detach( '/page_error_404_not_found', [] );
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Triage.pm b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm
new file mode 100644
index 000000000..428c35073
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Admin/Triage.pm
@@ -0,0 +1,163 @@
+package FixMyStreet::App::Controller::Admin::Triage;
+use Moose;
+use namespace::autoclean;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Admin::Triage - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Admin pages for triaging reports.
+
+This allows reports to be triaged before being sent to the council. It works
+by having a set of categories with a send_method of Triage which sets the report
+state to 'for_triage'. Any reports with the state are then show on '/admin/triage'
+which is available to users with the 'triage' permission.
+
+Clicking on reports on this list will then allow a user to change the category of
+the report to one that has an alternative send method, which will trigger the report
+to be resent.
+
+In order for this to work additional work needs to be done to the cobrand to only
+display triageable categories to the user.
+
+=head1 METHODS
+
+=cut
+
+sub auto : Private {
+ my ( $self, $c ) = @_;
+
+ unless ( $c->user->has_body_permission_to('triage') ) {
+ $c->detach('/page_error_403_access_denied', []);
+ }
+}
+
+sub index : Path : Args(0) {
+ my ( $self, $c ) = @_;
+
+ # default sort to oldest
+ unless ( $c->get_param('sort') ) {
+ $c->set_param('sort', 'created-asc');
+ }
+ $c->stash->{body} = $c->forward('/reports/body_find', [ $c->cobrand->council_area ]);
+ $c->forward( 'stash_report_filter_status' );
+ $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]);
+ $c->forward( '/reports/load_and_group_problems' );
+ $c->stash->{page} = 'reports'; # So the map knows to make clickable pins
+
+ if ($c->get_param('ajax')) {
+ my $ajax_template = $c->stash->{ajax_template} || 'reports/_problem-list.html';
+ $c->detach('/reports/ajax', [ $ajax_template ]);
+ }
+
+ my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, {
+ columns => [ 'id', 'category', 'extra' ],
+ distinct => 1,
+ } )->all_sorted;
+ $c->stash->{filter_categories} = \@categories;
+ $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) };
+ my $pins = $c->stash->{pins} || [];
+
+ my %map_params = (
+ latitude => @$pins ? $pins->[0]{latitude} : 0,
+ longitude => @$pins ? $pins->[0]{longitude} : 0,
+ area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ],
+ any_zoom => 1,
+ );
+ FixMyStreet::Map::display_map(
+ $c, %map_params, pins => $pins,
+ );
+}
+
+sub stash_report_filter_status : Private {
+ my ( $self, $c ) = @_;
+ $c->stash->{filter_problem_states} = { 'for triage' => 1 };
+ return 1;
+}
+
+sub setup_categories : Private {
+ my ( $self, $c ) = @_;
+
+ if ( $c->stash->{problem}->state eq 'for triage' ) {
+ $c->stash->{holding_options} = [ grep { $_->send_method && $_->send_method eq 'Triage' } @{$c->stash->{category_options}} ];
+ $c->stash->{holding_categories} = { map { $_->category => 1 } @{$c->stash->{holding_options}} };
+ $c->stash->{end_options} = [ grep { !$_->send_method || $_->send_method ne 'Triage' } @{$c->stash->{category_options}} ];
+ $c->stash->{end_categories} = { map { $_->category => 1 } @{$c->stash->{end_options}} };
+ delete $c->stash->{categories_hash};
+ my %category_groups = ();
+ for my $category (@{$c->stash->{end_options}}) {
+ my $group = $category->{group} // $category->get_extra_metadata('group') // [''];
+ # this could be an array ref or a string
+ my @groups = ref $group eq 'ARRAY' ? @$group : ($group);
+ push( @{$category_groups{$_}}, $category ) for @groups;
+ }
+ my @category_groups = ();
+ for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) {
+ push @category_groups, { name => $group, categories => $category_groups{$group} };
+ }
+ $c->stash->{end_groups} = \@category_groups;
+ }
+
+ return 1;
+}
+
+sub update : Private {
+ my ($self, $c) = @_;
+
+ my $problem = $c->stash->{problem};
+
+ my $current_category = $problem->category;
+ my $new_category = $c->get_param('category');
+
+ my $changed = $c->forward('/admin/reports/edit_category', [ $problem, 1 ] );
+
+ if ( $changed ) {
+ $c->stash->{problem}->update( { state => 'confirmed' } );
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'triage' ] );
+
+ my $name = $c->user->moderating_user_name;
+ my $extra = { is_superuser => 1 };
+ if ($c->user->from_body) {
+ delete $extra->{is_superuser};
+ $extra->{is_body_user} = $c->user->from_body->id;
+ }
+
+ $extra->{triage_report} = 1;
+ $extra->{holding_category} = $current_category;
+ $extra->{new_category} = $new_category;
+
+ my $timestamp = \'current_timestamp';
+ my $comment = $problem->add_to_comments( {
+ text => "Report triaged from $current_category to $new_category",
+ created => $timestamp,
+ confirmed => $timestamp,
+ user_id => $c->user->id,
+ name => $name,
+ mark_fixed => 0,
+ anonymous => 0,
+ state => 'confirmed',
+ problem_state => $problem->state,
+ extra => $extra,
+ whensent => \'current_timestamp',
+ } );
+
+ my @alerts = FixMyStreet::DB->resultset('Alert')->search( {
+ alert_type => 'new_updates',
+ parameter => $problem->id,
+ confirmed => 1,
+ } );
+
+ for my $alert (@alerts) {
+ my $alerts_sent = FixMyStreet::DB->resultset('AlertSent')->find_or_create( {
+ alert_id => $alert->id,
+ parameter => $comment->id,
+ } );
+ }
+ }
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Admin/Users.pm b/perllib/FixMyStreet/App/Controller/Admin/Users.pm
index bcbc808ed..046e19126 100644
--- a/perllib/FixMyStreet/App/Controller/Admin/Users.pm
+++ b/perllib/FixMyStreet/App/Controller/Admin/Users.pm
@@ -27,37 +27,69 @@ Admin pages for editing users
sub index :Path : Args(0) {
my ( $self, $c ) = @_;
- $c->detach('add') if $c->req->method eq 'POST'; # Add a user
-
- if (my $search = $c->get_param('search')) {
- $search = $self->trim($search);
- $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...>
- $c->stash->{searched} = $search;
-
- my $isearch = '%' . $search . '%';
- my $search_n = 0;
- $search_n = int($search) if $search =~ /^\d+$/;
-
- my $users = $c->cobrand->users->search(
- {
- -or => [
- email => { ilike => $isearch },
- phone => { ilike => $isearch },
- name => { ilike => $isearch },
- from_body => $search_n,
- ]
+ if ($c->req->method eq 'POST') {
+ my @uids = $c->get_param_list('uid');
+ my @role_ids = $c->get_param_list('roles');
+ my $user_rs = FixMyStreet::DB->resultset("User")->search({ id => \@uids });
+ foreach my $user ($user_rs->all) {
+ $user->admin_user_body_permissions->delete;
+ $user->user_roles->search({
+ role_id => { -not_in => \@role_ids },
+ })->delete;
+ foreach my $role (@role_ids) {
+ $user->user_roles->find_or_create({
+ role_id => $role,
+ });
}
- );
+ }
+ $c->stash->{status_message} = _('Updated!');
+ }
+
+ my $search = $c->get_param('search');
+ my $role = $c->get_param('role');
+ if ($search || $role) {
+ my $users = $c->cobrand->users;
+ my $isearch;
+ if ($search) {
+ $search = $self->trim($search);
+ $search =~ s/^<(.*)>$/$1/; # In case email wrapped in <...>
+ $c->stash->{searched} = $search;
+
+ $isearch = '%' . $search . '%';
+ my $search_n = 0;
+ $search_n = int($search) if $search =~ /^\d+$/;
+
+ $users = $users->search(
+ {
+ -or => [
+ email => { ilike => $isearch },
+ phone => { ilike => $isearch },
+ name => { ilike => $isearch },
+ from_body => $search_n,
+ ]
+ }
+ );
+ }
+ if ($role) {
+ $c->stash->{role_selected} = $role;
+ $users = $users->search({
+ role_id => $role,
+ }, {
+ join => 'user_roles',
+ });
+ }
+
my @users = $users->all;
$c->stash->{users} = [ @users ];
- $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]);
+ if ($search) {
+ $c->forward('/admin/add_flags', [ { email => { ilike => $isearch } } ]);
+ }
} else {
$c->forward('/auth/get_csrf_token');
$c->forward('/admin/fetch_all_bodies');
$c->cobrand->call_hook('admin_user_edit_extra_data');
-
# Admin users by default
my $users = $c->cobrand->users->search(
{ from_body => { '!=', undef } },
@@ -67,6 +99,14 @@ sub index :Path : Args(0) {
$c->stash->{users} = \@users;
}
+ my $rs;
+ if ($c->user->is_superuser) {
+ $rs = $c->model('DB::Role')->search_rs({}, { join => 'body', order_by => ['body.name', 'me.name'] });
+ } elsif ($c->user->from_body) {
+ $rs = $c->user->from_body->roles->search_rs({}, { order_by => 'name' });
+ }
+ $c->stash->{roles} = [ $rs->all ];
+
return 1;
}
@@ -113,9 +153,7 @@ sub add : Local : Args(0) {
$c->stash->{field_errors}->{username} = _('User already exists');
}
- return if %{$c->stash->{field_errors}};
-
- my $user = $c->model('DB::User')->create( {
+ my $user = $c->model('DB::User')->new( {
name => $c->get_param('name'),
email => $email ? $email : undef,
email_verified => $email && $email_v ? 1 : 0,
@@ -127,28 +165,48 @@ sub add : Local : Args(0) {
is_superuser => ( $c->user->is_superuser && $c->get_param('is_superuser') ) || 0,
} );
$c->stash->{user} = $user;
+
+ return if %{$c->stash->{field_errors}};
+
$c->forward('user_cobrand_extra_fields');
- $user->update;
+ $user->insert;
- $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'add' ] );
$c->flash->{status_message} = _("Updated!");
- $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) );
+ $c->detach('post_edit_redirect', [ $user ]);
}
-sub edit : Path : Args(1) {
- my ( $self, $c, $id ) = @_;
+sub fetch_body_roles : Private {
+ my ($self, $c, $body ) = @_;
- $c->forward('/auth/get_csrf_token');
+ my $roles = $body->roles->search(undef, { order_by => 'name' });
+ unless ($roles) {
+ delete $c->stash->{roles}; # Body doesn't have any roles
+ return;
+ }
+
+ $c->stash->{roles} = [ $roles->all ];
+}
+
+sub user : Chained('/') PathPart('admin/users') : CaptureArgs(1) {
+ my ( $self, $c, $id ) = @_;
my $user = $c->cobrand->users->find( { id => $id } );
$c->detach( '/page_error_404_not_found', [] ) unless $user;
+ $c->stash->{user} = $user;
unless ( $c->user->has_body_permission_to('user_edit') || $c->cobrand->moniker eq 'zurich' ) {
$c->detach('/page_error_403_access_denied', []);
}
+}
- $c->stash->{user} = $user;
+sub edit : Chained('user') : PathPart('') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->forward('/auth/get_csrf_token');
+
+ my $user = $c->stash->{user};
$c->forward( '/admin/check_username_for_abuse', [ $user ] );
if ( $user->from_body && $c->user->has_permission_to('user_manage_permissions', $user->from_body->id) ) {
@@ -157,11 +215,11 @@ sub edit : Path : Args(1) {
$c->forward('/admin/fetch_all_bodies');
$c->forward('/admin/fetch_body_areas', [ $user->from_body ]) if $user->from_body;
+ $c->forward('fetch_body_roles', [ $user->from_body ]) if $user->from_body;
$c->cobrand->call_hook('admin_user_edit_extra_data');
if ( defined $c->flash->{status_message} ) {
- $c->stash->{status_message} =
- '<p><em>' . $c->flash->{status_message} . '</em></p>';
+ $c->stash->{status_message} = $c->flash->{status_message};
}
$c->forward('/auth/check_csrf_token') if $c->get_param('submit');
@@ -179,14 +237,12 @@ sub edit : Path : Args(1) {
} elsif ( $c->get_param('submit') and $c->get_param('send_login_email') ) {
my $email = lc $c->get_param('email');
my %args = ( email => $email );
- $args{user_id} = $id if $user->email ne $email || !$user->email_verified;
+ $args{user_id} = $user->id if $user->email ne $email || !$user->email_verified;
$c->forward('send_login_email', [ \%args ]);
} elsif ( $c->get_param('update_alerts') ) {
$c->forward('update_alerts');
} elsif ( $c->get_param('submit') ) {
- my $edited = 0;
-
my $name = $c->get_param('name');
my $email = lc $c->get_param('email');
my $phone = $c->get_param('phone');
@@ -228,19 +284,10 @@ sub edit : Path : Args(1) {
return if %{$c->stash->{field_errors}};
- if ( ($user->email || "") ne $email ||
- $user->name ne $name ||
- ($user->phone || "") ne $phone ||
- ($user->from_body && $c->get_param('body') && $user->from_body->id ne $c->get_param('body')) ||
- (!$user->from_body && $c->get_param('body'))
- ) {
- $edited = 1;
- }
-
if ($existing_user_cobrand) {
$existing_user->adopt($user);
- $c->forward( '/admin/log_edit', [ $id, 'user', 'merge' ] );
- return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $existing_user->id ) );
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'merge' ] );
+ return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $existing_user->id ] ) );
}
$user->email($email) if !$existing_email;
@@ -270,26 +317,45 @@ sub edit : Path : Args(1) {
# If so, we need to re-fetch areas so the UI is up to date.
if ( $user->from_body && $user->from_body->id ne $c->stash->{fetched_areas_body_id} ) {
$c->forward('/admin/fetch_body_areas', [ $user->from_body ]);
+ $c->forward('fetch_body_roles', [ $user->from_body ]);
}
if (!$user->from_body) {
# Non-staff users aren't allowed any permissions or to be in an area
$user->admin_user_body_permissions->delete;
+ $user->user_roles->delete;
$user->area_ids(undef);
delete $c->stash->{areas};
+ delete $c->stash->{roles};
delete $c->stash->{fetched_areas_body_id};
} elsif ($c->stash->{available_permissions}) {
- my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} };
- my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions;
- $user->admin_user_body_permissions->search({
- body_id => $user->from_body->id,
- permission_type => { '!=' => \@user_permissions },
- })->delete;
- foreach my $permission_type (@user_permissions) {
- $user->user_body_permissions->find_or_create({
+ my %valid_roles = map { $_->id => 1 } @{$c->stash->{roles}};
+ my @role_ids = grep { $valid_roles{$_} } $c->get_param_list('roles');
+ if (@role_ids) {
+ # Roles take precedence over permissions
+ $user->admin_user_body_permissions->delete;
+ $user->user_roles->search({
+ role_id => { -not_in => \@role_ids },
+ })->delete;
+ foreach my $role (@role_ids) {
+ $user->user_roles->find_or_create({
+ role_id => $role,
+ });
+ }
+ } else {
+ $user->user_roles->delete;
+ my @all_permissions = map { keys %$_ } values %{ $c->stash->{available_permissions} };
+ my @user_permissions = grep { $c->get_param("permissions[$_]") ? 1 : undef } @all_permissions;
+ $user->admin_user_body_permissions->search({
body_id => $user->from_body->id,
- permission_type => $permission_type,
- });
+ permission_type => { -not_in => \@user_permissions },
+ })->delete;
+ foreach my $permission_type (@user_permissions) {
+ $user->user_body_permissions->find_or_create({
+ body_id => $user->from_body->id,
+ permission_type => $permission_type,
+ });
+ }
}
}
@@ -299,35 +365,6 @@ sub edit : Path : Args(1) {
$user->area_ids( @area_ids ? \@area_ids : undef );
}
- # Handle 'trusted' flag(s)
- my @trusted_bodies = $c->get_param_list('trusted_bodies');
- if ( $c->user->is_superuser ) {
- $user->user_body_permissions->search({
- body_id => { '!=' => \@trusted_bodies },
- permission_type => 'trusted',
- })->delete;
- foreach my $body_id (@trusted_bodies) {
- $user->user_body_permissions->find_or_create({
- body_id => $body_id,
- permission_type => 'trusted',
- });
- }
- } elsif ( $c->user->from_body ) {
- my %trusted = map { $_ => 1 } @trusted_bodies;
- my $body_id = $c->user->from_body->id;
- if ( $trusted{$body_id} ) {
- $user->user_body_permissions->find_or_create({
- body_id => $body_id,
- permission_type => 'trusted',
- });
- } else {
- $user->user_body_permissions->search({
- body_id => $body_id,
- permission_type => 'trusted',
- })->delete;
- }
- }
-
# Update the categories this user operates in
if ( $user->from_body ) {
$c->stash->{body} = $user->from_body;
@@ -336,14 +373,15 @@ sub edit : Path : Args(1) {
my @live_contact_ids = map { $_->id } @live_contacts;
my @new_contact_ids = grep { $c->get_param("contacts[$_]") } @live_contact_ids;
$user->set_extra_metadata('categories', \@new_contact_ids);
+ } else {
+ $user->unset_extra_metadata('categories');
}
$user->update;
- if ($edited) {
- $c->forward( '/admin/log_edit', [ $id, 'user', 'edit' ] );
- }
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->flash->{status_message} = _("Updated!");
- return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', $user->id ) );
+
+ $c->detach('post_edit_redirect', [ $user ]);
}
if ( $user->from_body ) {
@@ -358,8 +396,10 @@ sub edit : Path : Args(1) {
id => $_->id,
category => $_->category,
active => $active_contacts{$_->id},
+ group => $_->get_extra_metadata('group') // '',
} } @live_contacts;
$c->stash->{contacts} = \@all_contacts;
+ $c->forward('/report/stash_category_groups', [ \@all_contacts, 1 ]) if $c->cobrand->enable_category_groups;
}
# this goes after in case we've delete any alerts
@@ -370,6 +410,50 @@ sub edit : Path : Args(1) {
return 1;
}
+sub log : Chained('user') : PathPart('log') : Args(0) {
+ my ($self, $c) = @_;
+
+ my $user = $c->stash->{user};
+
+ my $after = $c->get_param('after');
+
+ my %time;
+ foreach ($user->admin_logs->all) {
+ push @{$time{$_->whenedited->epoch}}, { type => 'log', date => $_->whenedited, log => $_ };
+ }
+ foreach ($c->cobrand->problems->search({ extra => { like => '%contributed_by%' . $user->id . '%' } })->all) {
+ next unless $_->get_extra_metadata('contributed_by') == $user->id;
+ push @{$time{$_->created->epoch}}, { type => 'problemContributedBy', date => $_->created, obj => $_ };
+ }
+
+ foreach ($user->user_planned_reports->all) {
+ push @{$time{$_->added->epoch}}, { type => 'shortlistAdded', date => $_->added, obj => $_->report };
+ push @{$time{$_->removed->epoch}}, { type => 'shortlistRemoved', date => $_->removed, obj => $_->report } if $_->removed;
+ }
+
+ foreach ($user->problems->all) {
+ push @{$time{$_->created->epoch}}, { type => 'problem', date => $_->created, obj => $_ };
+ }
+
+ foreach ($user->comments->all) {
+ push @{$time{$_->created->epoch}}, { type => 'update', date => $_->created, obj => $_};
+ }
+
+ $c->stash->{time} = \%time;
+}
+
+sub post_edit_redirect : Private {
+ my ( $self, $c, $user ) = @_;
+
+ # User may not be visible on this cobrand, e.g. if their from_body
+ # wasn't set.
+ if ( $c->cobrand->users->find( { id => $user->id } ) ) {
+ return $c->res->redirect( $c->uri_for_action( 'admin/users/edit', [ $user->id ] ) );
+ } else {
+ return $c->res->redirect( $c->uri_for_action( 'admin/users/index' ) );
+ }
+}
+
sub import :Local {
my ( $self, $c, $id ) = @_;
@@ -387,11 +471,9 @@ sub import :Local {
my $csv = Text::CSV->new({ binary => 1});
my $fh = $c->req->upload('csvfile')->fh;
- $csv->getline($fh); # discard the header
- while (my $row = $csv->getline($fh)) {
- my ($name, $email, $from_body, $permissions) = @$row;
- $email = lc Utils::trim_text($email);
- my @permissions = split(/:/, $permissions);
+ $csv->header($fh);
+ while (my $row = $csv->getline_hr($fh)) {
+ my $email = lc Utils::trim_text($row->{email});
my $user = FixMyStreet::DB->resultset("User")->find_or_new({ email => $email, email_verified => 1 });
if ($user->in_storage) {
@@ -399,16 +481,29 @@ sub import :Local {
next;
}
- $user->name($name);
- $user->from_body($from_body || undef);
- $user->update_or_insert;
+ $user->name($row->{name});
+ $user->from_body($row->{from_body} || undef);
+ $user->password($row->{passwordhash}, 1) if $row->{passwordhash};
+ $user->insert;
- my @user_permissions = grep { $available_permissions{$_} } @permissions;
- foreach my $permission_type (@user_permissions) {
- $user->user_body_permissions->find_or_create({
- body_id => $user->from_body->id,
- permission_type => $permission_type,
- });
+ if ($row->{roles}) {
+ my @roles = split(/:/, $row->{roles});
+ foreach my $role (@roles) {
+ $role = FixMyStreet::DB->resultset("Role")->find({
+ body_id => $user->from_body->id,
+ name => $role,
+ }) or next;
+ $user->add_to_roles($role);
+ }
+ } else {
+ my @permissions = split(/:/, $row->{permissions});
+ my @user_permissions = grep { $available_permissions{$_} } @permissions;
+ foreach my $permission_type (@user_permissions) {
+ $user->user_body_permissions->find_or_create({
+ body_id => $user->from_body->id,
+ permission_type => $permission_type,
+ });
+ }
}
push @{$c->stash->{new_users}}, $user;
@@ -497,7 +592,7 @@ sub user_hide_everywhere : Private {
my ( $self, $c, $user ) = @_;
my $problems = $user->problems->search({ state => { '!=' => 'hidden' } });
while (my $problem = $problems->next) {
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
$problem->update({ state => 'hidden' });
}
my $updates = $user->comments->search({ state => { '!=' => 'hidden' } });
@@ -538,6 +633,7 @@ sub user_remove_account : Private {
my ( $self, $c, $user ) = @_;
$c->forward('user_logout_everywhere', [ $user ]);
$user->anonymize_account;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('That user’s personal details have been removed.');
}
@@ -565,6 +661,7 @@ sub ban : Private {
$c->stash->{status_message} = _('User already in abuse list');
} else {
$abuse->insert;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('User added to abuse list');
}
$c->stash->{username_in_abuse} = 1;
@@ -575,6 +672,7 @@ sub ban : Private {
$c->stash->{status_message} = _('User already in abuse list');
} else {
$abuse->insert;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('User added to abuse list');
}
$c->stash->{username_in_abuse} = 1;
@@ -596,6 +694,7 @@ sub unban : Private {
my $abuse = $c->model('DB::Abuse')->search({ email => \@username });
if ( $abuse ) {
$abuse->delete;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('user removed from abuse list');
} else {
$c->stash->{status_message} = _('user not in abuse list');
@@ -625,6 +724,7 @@ sub flag : Private {
} else {
$user->flagged(1);
$user->update;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('User flagged');
}
@@ -654,6 +754,7 @@ sub flag_remove : Private {
} else {
$user->flagged(0);
$user->update;
+ $c->forward( '/admin/log_edit', [ $user->id, 'user', 'edit' ] );
$c->stash->{status_message} = _('User flag removed');
}
diff --git a/perllib/FixMyStreet/App/Controller/Alert.pm b/perllib/FixMyStreet/App/Controller/Alert.pm
index 1060c080b..a42e7203a 100644
--- a/perllib/FixMyStreet/App/Controller/Alert.pm
+++ b/perllib/FixMyStreet/App/Controller/Alert.pm
@@ -58,12 +58,15 @@ sub subscribe : Path('subscribe') : Args(0) {
$c->detach('rss') if $c->get_param('rss');
+ my $id = $c->get_param('id');
+ $c->forward('/report/load_problem_or_display_error', [ $id ]) if $id;
+
# if it exists then it's been submitted so we should
# go to subscribe email and let it work out the next step
$c->detach('subscribe_email')
if $c->get_param('rznvy') || $c->get_param('alert');
- $c->go('updates') if $c->get_param('id');
+ $c->go('updates') if $id;
# shouldn't get to here but if we have then do something sensible
$c->go('index');
@@ -148,7 +151,7 @@ sub updates : Path('updates') : Args(0) {
$c->forward('/auth/get_csrf_token');
$c->stash->{email} = $c->get_param('rznvy');
- $c->stash->{problem_id} = $c->get_param('id');
+ $c->stash->{email} ||= $c->user->email if $c->user_exists;
}
=head2 confirm
@@ -193,7 +196,7 @@ sub create_alert : Private {
$alert->insert();
}
- if ( $c->user && $c->user->id == $alert->user->id ) {
+ if ( $c->user_exists && ($c->user->id == $alert->user->id || $c->stash->{can_create_for_another})) {
$alert->confirm();
} else {
$alert->confirmed(0);
@@ -211,13 +214,10 @@ Set up the options in the stash required to create a problem update alert
sub set_update_alert_options : Private {
my ( $self, $c ) = @_;
- my $report_id = $c->get_param('id');
- return unless $report_id =~ /^[1-9]\d*$/;
-
my $options = {
user => $c->stash->{alert_user},
alert_type => 'new_updates',
- parameter => $report_id,
+ parameter => $c->stash->{problem}->id,
};
$c->stash->{alert_options} = $options;
@@ -283,7 +283,7 @@ sub send_confirmation_email : Private {
my $user = $c->stash->{alert}->user;
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
my $token = $c->model("DB::Token")->create(
@@ -340,16 +340,16 @@ sub process_user : Private {
my ( $self, $c ) = @_;
if ( $c->user_exists ) {
- $c->stash->{alert_user} = $c->user->obj;
- return;
+ $c->stash->{can_create_for_another} = $c->stash->{problem}
+ && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids);
+ if (!$c->stash->{can_create_for_another}) {
+ $c->stash->{alert_user} = $c->user->obj;
+ return;
+ }
}
- # Extract all the params to a hash to make them easier to work with
- my %params = map { $_ => $c->get_param($_) }
- ( 'rznvy' ); # , 'password_register' );
-
- # cleanup the email address
- my $email = $params{rznvy} ? lc $params{rznvy} : '';
+ my $email = $c->get_param('rznvy');
+ $email = $email ? lc $email : '';
$email =~ s{\s+}{}g;
push @{ $c->stash->{errors} }, _('Please enter a valid email address')
@@ -357,19 +357,6 @@ sub process_user : Private {
my $alert_user = $c->model('DB::User')->find_or_new( { email => $email } );
$c->stash->{alert_user} = $alert_user;
-
-# # The user is trying to sign in. We only care about email from the params.
-# if ( $c->get_param('submit_sign_in') ) {
-# unless ( $c->forward( '/auth/sign_in', [ $email ] ) ) {
-# $c->stash->{field_errors}->{password} = _('There was a problem with your email/password combination. Please try again.');
-# return 1;
-# }
-# my $user = $c->user->obj;
-# $c->stash->{alert_user} = $user;
-# return 1;
-# }
-#
-# $alert_user->password( $params{password_register} );
}
=head2 setup_coordinate_rss_feeds
diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm
index a09161494..af50f1883 100644
--- a/perllib/FixMyStreet/App/Controller/Around.pm
+++ b/perllib/FixMyStreet/App/Controller/Around.pm
@@ -231,27 +231,37 @@ sub check_and_stash_category : Private {
my $all_areas = $c->stash->{all_areas};
my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all;
my %bodies = map { $_->id => $_ } @bodies;
+ $c->cobrand->call_hook(munge_report_new_bodies => \%bodies); # To match setup_categories_and_bodies in New.pm
+
my @list_of_names = map { $_->name } values %bodies;
my $csv = Text::CSV->new();
$csv->combine(@list_of_names);
+ $c->stash->{around_bodies} = \@bodies;
+ $c->stash->{bodies_ids} = [ map { $_->id } @bodies];
$c->{stash}->{list_of_names_as_string} = $csv->string;
+ my $where = { body_id => [ keys %bodies ], };
+
+ my $cobrand_where = $c->cobrand->call_hook('munge_around_category_where', $where );
+ if ( $cobrand_where ) {
+ $where = $cobrand_where;
+ }
+
my @categories = $c->model('DB::Contact')->not_deleted->search(
- {
- body_id => [ keys %bodies ],
- },
+ $where,
{
columns => [ 'category', 'extra' ],
- order_by => [ 'category' ],
distinct => 1
}
- )->all;
+ )->all_sorted;
$c->stash->{filter_categories} = \@categories;
my %categories_mapped = map { $_->category => 1 } @categories;
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $categories = [ $c->get_param_list('filter_category', 1) ];
my %valid_categories = map { $_ => 1 } grep { $_ && $categories_mapped{$_} } @$categories;
$c->stash->{filter_category} = \%valid_categories;
+ $c->cobrand->call_hook('munge_around_filter_category_list');
}
sub map_features : Private {
@@ -312,6 +322,7 @@ sub ajax : Path('/ajax') {
my %valid_categories = map { $_ => 1 } $c->get_param_list('filter_category', 1);
$c->stash->{filter_category} = \%valid_categories;
+ $c->cobrand->call_hook('munge_around_filter_category_list');
$c->forward('map_features', [ { bbox => $c->stash->{bbox} } ]);
$c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]);
@@ -321,12 +332,14 @@ sub nearby : Path {
my ($self, $c) = @_;
my $states = FixMyStreet::DB::Result::Problem->open_states();
- $c->forward('/report/_nearby_json', [ {
+ my $params = {
latitude => $c->get_param('latitude'),
longitude => $c->get_param('longitude'),
categories => [ $c->get_param('filter_category') || () ],
states => $states,
- } ]);
+ };
+ $c->cobrand->call_hook('around_nearby_filter', $params);
+ $c->forward('/report/_nearby_json', [ $params ]);
}
sub location_closest_address : Path('/ajax/closest') {
@@ -416,7 +429,7 @@ sub lookup_by_ref : Private {
external_id => $ref
];
- my $problems = $c->cobrand->problems->search( $criteria );
+ my $problems = $c->cobrand->problems->search({ non_public => 0, -or => $criteria });
my $count = try {
$problems->count;
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm
index c194045b9..cecfa318c 100644
--- a/perllib/FixMyStreet/App/Controller/Auth.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth.pm
@@ -44,13 +44,12 @@ sub general : Path : Args(0) {
# decide which action to take
$c->detach('code_sign_in') if $clicked_sign_in_by_code || ($data_email && !$data_password);
- if (!$data_username && !$data_password && !$data_email) {
- $c->detach('social/facebook_sign_in') if $c->get_param('facebook_sign_in');
- $c->detach('social/twitter_sign_in') if $c->get_param('twitter_sign_in');
+ if (!$data_username && !$data_password && !$data_email && $c->get_param('social_sign_in')) {
+ $c->forward('social/handle_sign_in');
}
- $c->forward( 'sign_in', [ $data_username ] )
- && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] );
+ $c->forward( 'sign_in', [ $data_username ] )
+ && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] );
}
@@ -68,6 +67,25 @@ sub forgot : Path('forgot') : Args(0) {
$c->detach('code_sign_in');
}
+sub expired : Path('expired') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ $c->detach('/page_error_403_access_denied', []) unless $c->user_exists;
+
+ my $expiry = $c->cobrand->call_hook('password_expiry');
+ $c->detach('/page_error_403_access_denied', []) unless $expiry;
+
+ my $last_change = $c->user->get_extra_metadata('last_password_change') || 0;
+ my $midnight = int(time()/86400)*86400;
+ my $expired = $last_change + $expiry < $midnight;
+ $c->detach('/page_error_403_access_denied', []) unless $expired;
+
+ $c->stash->{expired_password} = 1;
+ $c->stash->{template} = 'auth/create.html';
+ return unless $c->req->method eq 'POST';
+ $c->detach('code_sign_in', [ $c->user->email ]);
+}
+
sub authenticate : Private {
my ($self, $c, $type, $username, $password) = @_;
return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password });
@@ -122,9 +140,9 @@ they come back with a token (which contains the email/phone).
=cut
sub code_sign_in : Private {
- my ( $self, $c ) = @_;
+ my ( $self, $c, $override_username ) = @_;
- my $username = $c->stash->{username} = $c->get_param('username') || '';
+ my $username = $c->stash->{username} = $override_username || $c->get_param('username') || '';
my $parsed = FixMyStreet::SMS->parse_username($username);
@@ -180,10 +198,13 @@ sub email_sign_in : Private {
name => $c->get_param('name'),
password => $user->password,
};
- $token_data->{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $token_data->{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+
+ if ($c->get_param('oauth_need_email')) {
+ $token_data->{name} = $c->session->{oauth}{name}
+ if $c->session->{oauth}{name} && !$token_data->{name};
+ $c->forward('set_oauth_token_data', [ $token_data ]);
+ }
+
if ($c->stash->{current_user}) {
$token_data->{old_user_id} = $c->stash->{current_user}->id;
$token_data->{r} = 'auth/change_email/success';
@@ -214,6 +235,14 @@ sub get_token : Private {
return $data;
}
+sub set_oauth_token_data : Private {
+ my ( $self, $c, $token_data ) = @_;
+
+ foreach (qw/facebook_id twitter_id oidc_id extra logout_redirect_uri change_password_uri/) {
+ $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_};
+ }
+}
+
=head2 token
Handle the 'email_sign_in' tokens. Find the account for the email address
@@ -231,11 +260,11 @@ sub token : Path('/M') : Args(1) {
&& (!$c->user_exists || $c->user->id ne $data->{old_user_id});
my $type = $data->{login_type} || 'email';
- $c->detach( '/auth/process_login', [ $data, $type ] );
+ $c->detach( '/auth/process_login', [ $data, $type, $url_token ] );
}
sub process_login : Private {
- my ( $self, $c, $data, $type ) = @_;
+ my ( $self, $c, $data, $type, $url_token ) = @_;
# sign out in case we are another user
$c->logout();
@@ -247,8 +276,15 @@ sub process_login : Private {
$c->detach( '/page_error_403_access_denied', [] )
if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id};
- # Superusers using 2FA can not log in by code
- $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
+ # People using 2FA need to supply a code
+ my $must_have_2fa = $c->cobrand->call_hook('must_have_2fa', $user) || '';
+ if ($must_have_2fa ne 'skip') {
+ if ($user->has_2fa) {
+ $c->forward( 'token_2fa', [ $user, $url_token ] );
+ } elsif ($c->cobrand->call_hook('must_have_2fa', $user)) {
+ $c->forward( 'signup_2fa', [ $user ] );
+ }
+ }
if ($data->{old_user_id}) {
# Were logged in as old_user_id, want to switch to $user
@@ -272,13 +308,74 @@ sub process_login : Private {
$user->password( $data->{password}, 1 ) if $data->{password};
$user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id};
$user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id};
+ $user->add_oidc_id( $data->{oidc_id} ) if $data->{oidc_id};
+ $user->extra({
+ %{ $user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
+
$user->update_or_insert;
$c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' );
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
+
+
# send the user to their page
$c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] );
}
+=head2 token_2fa
+
+Used after clicking an email token link to request a 2FA code
+
+=cut
+
+sub token_2fa : Private {
+ my ($self, $c, $user, $url_token) = @_;
+
+ return if $c->check_2fa($user->has_2fa);
+
+ $c->stash->{form_action} = $c->req->path;
+ $c->stash->{token} = $url_token;
+ $c->stash->{template} = 'auth/2fa/form.html';
+ $c->detach;
+}
+
+sub signup_2fa : Private {
+ my ($self, $c, $user) = @_;
+
+ $c->stash->{form_action} = $c->req->path;
+ $c->stash->{template} = 'auth/2fa/intro.html';
+ my $action = $c->get_param('2fa_action') || '';
+
+ my $secret;
+ if ($action eq 'confirm') {
+ $secret = $c->get_param('secret32');
+ if ($c->check_2fa($secret)) {
+ $user->set_extra_metadata('2fa_secret' => $secret);
+ $user->update;
+ $c->stash->{stage} = 'success';
+ return;
+ } else {
+ $action = 'activate'; # Incorrect code, reshow
+ }
+ }
+
+ if ($action eq 'activate') {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code($secret, $user->email, $c->cobrand->base_url);
+ $c->stash->{secret32} = $auth->secret32;
+ $c->stash->{stage} = 'activate';
+ }
+
+ $c->detach;
+}
+
=head2 redirect_on_signin
Used after signing in to take the person back to where they were.
@@ -294,8 +391,11 @@ sub redirect_on_signin : Private {
}
unless ( $redirect ) {
- $c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories };
- $redirect = 'my';
+ my $inspector = $c->user->from_body && (
+ scalar @{ $c->user->categories } ||
+ scalar @{ $c->user->area_ids || [] }
+ );
+ $redirect = $inspector ? 'my/inspector_redirect' : 'my';
}
$redirect = 'my' if $redirect =~ /^admin/ && !$c->cobrand->admin_allow_user($c->user);
if ( $c->cobrand->moniker eq 'zurich' ) {
@@ -308,22 +408,6 @@ sub redirect_on_signin : Private {
}
}
-=head2 redirect_to_categories
-
-Redirects the user to their body's reports page, prefiltered to whatever
-categories this user has been assigned to.
-
-=cut
-
-sub redirect_to_categories : Private {
- my ( $self, $c ) = @_;
-
- my $categories = $c->user->categories_string;
- my $body_short = $c->cobrand->short_name( $c->user->from_body );
-
- $c->res->redirect( $c->uri_for( "/reports/" . $body_short, { filter_category => $categories } ) );
-}
-
=head2 redirect
Used when trying to view a page that requires sign in when you're not.
@@ -429,6 +513,12 @@ Log the user out. Tell them we've done so.
sub sign_out : Local {
my ( $self, $c ) = @_;
$c->logout();
+
+ if ( $c->sessionid && $c->session->{oauth} && $c->session->{oauth}{logout_redirect_uri} ) {
+ $c->response->redirect($c->session->{oauth}{logout_redirect_uri});
+ delete $c->session->{oauth}{logout_redirect_uri};
+ $c->detach;
+ }
}
sub ajax_sign_in : Path('ajax/sign_in') {
@@ -436,7 +526,8 @@ sub ajax_sign_in : Path('ajax/sign_in') {
my $return = {};
if ( $c->forward( 'sign_in', [ $c->get_param('email') ] ) ) {
- $return->{name} = $c->user->name;
+ $return->{name} = $c->user->name || '-'; # App currently requires something returned
+ $return->{success} = 1;
} else {
$return->{error} = 1;
}
@@ -509,6 +600,11 @@ sub check_auth : Local {
return;
}
+sub two_factor_setup_success : Private {
+ my ($self, $c) = @_;
+ # Only here to be detached to after setup success
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
index 87aff2261..a89c6f539 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
@@ -74,7 +74,8 @@ sub change_password : Path('/auth/change_password') {
if ($c->user->password) {
# we should have a usable password - save it to the user
- $c->user->obj->update( { password => $new } );
+ $c->user->obj->password($new);
+ $c->user->obj->update;
$c->stash->{password_changed} = 1;
} else {
# Set up arguments for code sign in
@@ -188,23 +189,38 @@ sub generate_token : Path('/auth/generate_token') {
if ($c->get_param('generate_token')) {
my $token = mySociety::AuthToken::random_token();
$c->user->set_extra_metadata('access_token', $token);
+ $c->user->update;
$c->stash->{token_generated} = 1;
}
- if ($c->get_param('toggle_2fa') && $c->user->is_superuser) {
- if ($has_2fa) {
- $c->user->unset_extra_metadata('2fa_secret');
- $c->stash->{toggle_2fa_off} = 1;
+ my $action = $c->get_param('2fa_action') || '';
+ $action = 'deactivate' if $c->get_param('2fa_deactivate');
+ $action = 'activate' if $c->get_param('2fa_activate');
+ $action = 'activate' if $action eq 'deactivate' && $has_2fa && $c->cobrand->call_hook('must_have_2fa', $c->user);
+
+ my $secret;
+ if ($action eq 'deactivate') {
+ $c->user->unset_extra_metadata('2fa_secret');
+ $c->user->update;
+ $c->stash->{toggle_2fa_off} = 1;
+ } elsif ($action eq 'confirm') {
+ $secret = $c->get_param('secret32');
+ if ($c->check_2fa($secret)) {
+ $c->user->set_extra_metadata('2fa_secret', $secret);
+ $c->user->update;
+ $c->stash->{stage} = 'success';
+ $has_2fa = 1;
} else {
- my $auth = Auth::GoogleAuth->new;
- $c->stash->{qr_code} = $auth->qr_code(undef, $c->user->email, 'FixMyStreet');
- $c->stash->{secret32} = $auth->secret32;
- $c->user->set_extra_metadata('2fa_secret', $auth->secret32);
- $c->stash->{toggle_2fa_on} = 1;
+ $action = 'activate'; # Incorrect code, reshow
}
}
- $c->user->update();
+ if ($action eq 'activate') {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code($secret, $c->user->email, $c->cobrand->base_url);
+ $c->stash->{secret32} = $auth->secret32;
+ $c->stash->{stage} = 'activate';
+ }
}
$c->stash->{has_2fa} = $has_2fa ? 1 : 0;
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm
index 097cac984..06e67573f 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Social.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Social.pm
@@ -6,6 +6,10 @@ BEGIN { extends 'Catalyst::Controller'; }
use Net::Facebook::Oauth2;
use Net::Twitter::Lite::WithAPIv1_1;
+use OIDC::Lite::Client::WebServer::Azure;
+use URI::Escape;
+
+use mySociety::AuthToken;
=head1 NAME
@@ -13,10 +17,26 @@ FixMyStreet::App::Controller::Auth::Social - Catalyst Controller
=head1 DESCRIPTION
-Controller for the Facebook/Twitter authentication.
+Controller for the Facebook/Twitter/OpenID Connect authentication.
=head1 METHODS
+=head2 handle_sign_in
+
+Forwards to the appropriate (facebook|twitter|oidc)_sign_in method
+based on the social_sign_in parameter
+
+=cut
+
+sub handle_sign_in : Private {
+ my ($self, $c) = @_;
+
+ $c->detach('facebook_sign_in') if $c->get_param('social_sign_in') eq 'facebook';
+ $c->detach('twitter_sign_in') if $c->get_param('social_sign_in') eq 'twitter';
+ $c->detach('oidc_sign_in') if $c->get_param('social_sign_in') eq 'oidc';
+
+}
+
=head2 facebook_sign_in
Starts the Facebook authentication sequence.
@@ -142,6 +162,166 @@ sub twitter_callback: Path('/auth/Twitter') : Args(0) {
$c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]);
}
+sub oidc : Private {
+ my ($self, $c) = @_;
+
+ my $config = $c->cobrand->feature('oidc_login');
+
+ OIDC::Lite::Client::WebServer::Azure->new(
+ id => $config->{client_id},
+ secret => $config->{secret},
+ authorize_uri => $config->{auth_uri},
+ access_token_uri => $config->{token_uri},
+ );
+}
+
+sub oidc_sign_in : Private {
+ my ( $self, $c ) = @_;
+
+ $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED');
+ $c->detach( '/page_error_400_bad_request', [] ) unless $c->cobrand->feature('oidc_login');
+
+ my $oidc = $c->forward('oidc');
+ my $nonce = $self->generate_nonce();
+ my $url = $oidc->uri_to_redirect(
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'login',
+ extra => {
+ response_mode => 'form_post',
+ nonce => $nonce,
+ },
+ );
+
+ my %oauth;
+ $oauth{return_url} = $c->get_param('r');
+ $oauth{detach_to} = $c->stash->{detach_to};
+ $oauth{detach_args} = $c->stash->{detach_args};
+ $oauth{nonce} = $nonce;
+
+ # The OIDC endpoint may require a specific URI to be called to log the user
+ # out when they log out of FMS.
+ if ( my $redirect_uri = $c->cobrand->feature('oidc_login')->{logout_uri} ) {
+ $redirect_uri .= "?post_logout_redirect_uri=";
+ $redirect_uri .= URI::Escape::uri_escape( $c->uri_for('/auth/sign_out') );
+ $oauth{logout_redirect_uri} = $redirect_uri;
+ }
+
+ # The OIDC endpoint may provide a specific URI for changing the user's password.
+ if ( my $password_change_uri = $c->cobrand->feature('oidc_login')->{password_change_uri} ) {
+ $oauth{change_password_uri} = $oidc->uri_to_redirect(
+ uri => $password_change_uri,
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'password_change',
+ extra => {
+ response_mode => 'form_post',
+ },
+ );
+ }
+
+ $c->session->{oauth} = \%oauth;
+ $c->res->redirect($url);
+}
+
+sub oidc_callback: Path('/auth/OIDC') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $oidc = $c->forward('oidc');
+
+ if ($c->get_param('error')) {
+ my $error_desc = $c->get_param('error_description');
+ my $password_reset_uri = $c->cobrand->feature('oidc_login')->{password_reset_uri};
+ if ($password_reset_uri && $error_desc =~ /^AADB2C90118:/) {
+ my $url = $oidc->uri_to_redirect(
+ uri => $password_reset_uri,
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'password_reset',
+ extra => {
+ response_mode => 'form_post',
+ },
+ );
+ $c->res->redirect($url);
+ $c->detach;
+ } elsif ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->flash->{flash_message} = _('Password change cancelled.');
+ $c->res->redirect('/my');
+ $c->detach;
+ } else {
+ $c->detach('oauth_failure');
+ }
+ }
+ $c->detach('/page_error_400_bad_request', []) unless $c->get_param('code') && $c->get_param('state');
+
+ # After a password reset on the OIDC endpoint the user isn't properly logged
+ # in, so redirect them to the usual OIDC login process.
+ if ( $c->get_param('state') eq 'password_reset' ) {
+ # The user may have reset their password as part of the sign-in-during-report
+ # process, so preserve their report and redirect them to the right place
+ # if that happened.
+ if ( $c->session->{oauth} ) {
+ $c->stash->{detach_to} = $c->session->{oauth}{detach_to};
+ $c->stash->{detach_args} = $c->session->{oauth}{detach_args};
+ }
+ $c->detach('oidc_sign_in', []);
+ }
+
+ # User may be coming back here after changing their password on the OIDC endpoint
+ if ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->detach('/auth/profile/change_password_success', []);
+ }
+
+ # The only other valid state param is 'login' at this point.
+ $c->detach('/page_error_400_bad_request', []) unless $c->get_param('state') eq 'login';
+
+ my $id_token;
+ eval {
+ $id_token = $oidc->get_access_token(
+ code => $c->get_param('code'),
+ );
+ };
+ if ($@) {
+ (my $message = $@) =~ s/at [^ ]*Auth.pm.*//;
+ $c->detach('/page_error_500_internal_error', [ $message ]);
+ }
+
+ $c->detach('oauth_failure') unless $id_token;
+
+ # sanity check the token audience is us...
+ $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{aud} eq $c->cobrand->feature('oidc_login')->{client_id};
+
+ # check that the nonce matches what we set in the user session
+ $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{nonce} eq $c->session->{oauth}{nonce};
+
+ # Some claims need parsing into a friendlier format
+ # XXX check how much of this is Westminster/Azure-specific
+ my $name = join(" ", $id_token->payload->{given_name}, $id_token->payload->{family_name});
+ my $email = $id_token->payload->{email};
+ # WCC Azure provides a single email address as an array for some reason
+ my $emails = $id_token->payload->{emails};
+ if ($emails && @$emails) {
+ $email = $emails->[0];
+ }
+
+ # There's a chance that a user may have multiple OIDC logins, so build a namespaced uid to prevent collisions
+ my $uid = join(":", $c->cobrand->moniker, $c->cobrand->feature('oidc_login')->{client_id}, $id_token->payload->{sub});
+
+ # The cobrand may want to set values in the user extra field, e.g. a CRM ID
+ # which is passed to Open311 with reports made by this user.
+ my $extra = $c->cobrand->call_hook(oidc_user_extra => $id_token);
+
+ $c->forward('oauth_success', [ 'oidc', $uid, $name, $email, $extra ]);
+}
+
+# Just a wrapper around random_token to make mocking easier.
+sub generate_nonce : Private {
+ my ($self, $c) = @_;
+
+ return mySociety::AuthToken::random_token();
+}
+
+
sub oauth_failure : Private {
my ( $self, $c ) = @_;
@@ -155,30 +335,64 @@ sub oauth_failure : Private {
}
sub oauth_success : Private {
- my ($self, $c, $type, $uid, $name, $email) = @_;
+ my ($self, $c, $type, $uid, $name, $email, $extra) = @_;
my $user;
if ($email) {
- # Only Facebook gets here
+ # Only Facebook & OIDC get here
# We've got an ID and an email address
+
# Remove any existing mention of this ID
- my $existing = $c->model('DB::User')->find( { facebook_id => $uid } );
- $existing->update( { facebook_id => undef } ) if $existing;
- # Get or create a user, give it this Facebook ID
+ my $existing;
+ if ($type eq 'facebook') {
+ $existing = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ $existing->update( { $type . '_id' => undef } ) if $existing;
+ } elsif ( $type eq 'oidc' ) {
+ $existing = $c->model('DB::User')->find( { oidc_ids => \[
+ '&& ?', [ oidc_ids => [ $uid ] ]
+ ] } );
+ $existing->remove_oidc_id( $uid ) if $existing;
+ }
+
+ # Get or create a user, give it this Facebook/OIDC ID
$user = $c->model('DB::User')->find_or_new( { email => $email } );
- $user->facebook_id($uid);
+ if ( $type eq 'facebook' ) {
+ $user->facebook_id($uid);
+ } elsif ( $type eq 'oidc' ) {
+ $user->add_oidc_id($uid);
+ }
$user->name($name);
+ if ($extra) {
+ $user->extra({
+ %{ $user->get_extra() },
+ %$extra
+ });
+ }
$user->in_storage() ? $user->update : $user->insert;
} else {
# We've got an ID, but no email
- $user = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ if ($type eq 'oidc') {
+ $user = $c->model('DB::User')->find( { oidc_ids => \[
+ '&& ?', [ oidc_ids => [ $uid ] ]
+ ] } );
+ } else {
+ $user = $c->model('DB::User')->find( { $type . '_id' => $uid } );
+ }
if ($user) {
# Matching ID in our database
$user->name($name);
+ if ($extra) {
+ $user->extra({
+ %{ $user->get_extra() },
+ %$extra
+ });
+ }
$user->update;
} else {
# No matching ID, store ID for use later
$c->session->{oauth}{$type . '_id'} = $uid;
+ $c->session->{oauth}{name} = $name;
+ $c->session->{oauth}{extra} = $extra;
$c->stash->{oauth_need_email} = 1;
}
}
diff --git a/perllib/FixMyStreet/App/Controller/Contact.pm b/perllib/FixMyStreet/App/Controller/Contact.pm
index fb525fc1f..9ce89a9e2 100644
--- a/perllib/FixMyStreet/App/Controller/Contact.pm
+++ b/perllib/FixMyStreet/App/Controller/Contact.pm
@@ -7,6 +7,7 @@ BEGIN { extends 'Catalyst::Controller'; }
use MIME::Base64;
use mySociety::EmailUtil;
use FixMyStreet::Email;
+use FixMyStreet::Template::SafeString;
=head1 NAME
@@ -26,11 +27,15 @@ Functions to run on both GET and POST contact requests.
sub auto : Private {
my ($self, $c) = @_;
- $c->forward('setup_request');
- $c->forward('determine_contact_type');
$c->forward('/auth/get_csrf_token');
}
+sub begin : Private {
+ my ($self, $c) = @_;
+ $c->forward('/begin');
+ $c->forward('setup_request');
+}
+
=head2 index
Display contact us page
@@ -39,6 +44,7 @@ Display contact us page
sub index : Path : Args(0) {
my ( $self, $c ) = @_;
+ $c->forward('determine_contact_type');
}
=head2 submit
@@ -50,6 +56,7 @@ Handle contact us form submission
sub submit : Path('submit') : Args(0) {
my ( $self, $c ) = @_;
+ $c->forward('determine_contact_type');
$c->res->redirect( '/contact' ) and return unless $c->req->method eq 'POST';
$c->go('index') unless $c->forward('validate');
@@ -87,11 +94,11 @@ sub determine_contact_type : Private {
} elsif ($id) {
$c->forward( '/report/load_problem_or_display_error', [ $id ] );
if ($update_id) {
- my $update = $c->model('DB::Comment')->search(
+ my $update = $c->cobrand->updates->search(
{
- id => $update_id,
+ "me.id" => $update_id,
problem_id => $id,
- state => 'confirmed',
+ "me.state" => 'confirmed',
}
)->first;
@@ -106,7 +113,14 @@ sub determine_contact_type : Private {
$c->stash->{rejecting_report} = 1;
}
} elsif ( $c->cobrand->abuse_reports_only ) {
- $c->detach( '/page_error_404_not_found' );
+ # General enquiries replaces contact form if enabled
+ if ( $c->cobrand->can('setup_general_enquiries_stash') ) {
+ $c->res->redirect( '/contact/enquiry' );
+ $c->detach;
+ return 1;
+ } else {
+ $c->detach( '/page_error_404_not_found' );
+ }
}
return 1;
@@ -185,6 +199,17 @@ sub prepare_params_for_email : Private {
my $base_url = $c->cobrand->base_url();
my $admin_url = $c->cobrand->admin_base_url;
+ my $user = $c->cobrand->users->find( { email => $c->stash->{em} } );
+ if ( $user ) {
+ $c->stash->{user_admin_url} = $admin_url . '/users/' . $user->id;
+ $c->stash->{user_reports_admin_url} = $admin_url . '/reports?search=' . $user->email;
+
+ my $user_latest_problem = $user->latest_visible_problem();
+ if ( $user_latest_problem) {
+ $c->stash->{user_latest_report_admin_url} = $admin_url . '/report_edit/' . $user_latest_problem->id;
+ }
+ }
+
if ( $c->stash->{update} ) {
$c->stash->{problem_url} = $base_url . $c->stash->{update}->url;
@@ -229,8 +254,9 @@ generally required to stash
sub setup_request : Private {
my ( $self, $c ) = @_;
- $c->stash->{contact_email} = $c->cobrand->contact_email;
- $c->stash->{contact_email} =~ s/\@/&#64;/;
+ my $email = $c->cobrand->contact_email;
+ $email =~ s/\@/&#64;/;
+ $c->stash->{contact_email} = FixMyStreet::Template::SafeString->new($email);
for my $param (qw/em subject message/) {
$c->stash->{$param} = $c->get_param($param);
diff --git a/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm
new file mode 100644
index 000000000..5b1c4980f
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Contact/Enquiry.pm
@@ -0,0 +1,119 @@
+package FixMyStreet::App::Controller::Contact::Enquiry;
+
+use Moose;
+use namespace::autoclean;
+use Path::Tiny;
+use File::Copy;
+use Digest::SHA qw(sha1_hex);
+use File::Basename;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+sub auto : Private {
+ my ($self, $c) = @_;
+
+ unless ( $c->cobrand->call_hook('setup_general_enquiries_stash') ) {
+ $c->res->redirect( '/' );
+ $c->detach;
+ }
+}
+
+# This needs to be defined here so /contact/begin doesn't get run instead.
+sub begin : Private {
+ my ($self, $c) = @_;
+
+ $c->forward('/begin');
+}
+
+sub index : Path : Args(0) {
+ my ( $self, $c, $preserve_session ) = @_;
+
+ # Make sure existing files aren't lost if we're rendering this
+ # page as a result of validation error.
+ delete $c->session->{enquiry_files} unless $preserve_session;
+
+ $c->stash->{field_errors}->{name} = _("Please enter your full name.") if $c->stash->{field_errors}->{name};
+}
+
+sub submit : Path('submit') : Args(0) {
+ my ( $self, $c ) = @_;
+
+ unless ($c->req->method eq 'POST' && $c->forward("/report/new/check_form_submitted") ) {
+ $c->res->redirect( '/contact/enquiry' );
+ return;
+ }
+
+ # General enquiries are always private reports, and aren't
+ # located by the user on the map
+ $c->set_param('non_public', 1);
+ $c->set_param('pc', '');
+ $c->set_param('skipped', 1);
+
+ $c->forward('/report/new/initialize_report');
+ $c->forward('/report/new/check_for_category');
+ $c->forward('/auth/check_csrf_token');
+ $c->forward('/report/new/process_report');
+ $c->forward('/report/new/process_user');
+ $c->forward('handle_uploads');
+ $c->forward('/photo/process_photo');
+ $c->go('index', [ 1 ]) unless $c->forward('/report/new/check_for_errors');
+ $c->forward('/report/new/save_user_and_report');
+ $c->forward('confirm_report');
+ $c->stash->{success} = 1;
+
+ # Don't want these lingering around for the next time.
+ delete $c->session->{enquiry_files};
+}
+
+sub confirm_report : Private {
+ my ( $self, $c ) = @_;
+
+ my $report = $c->stash->{report};
+
+ # We don't ever want to modify an existing user, as general enquiries don't
+ # require any kind of email confirmation.
+ $report->user->insert unless $report->user->in_storage;
+ $report->confirm();
+ $report->update;
+}
+
+sub handle_uploads : Private {
+ my ( $self, $c ) = @_;
+
+ # NB. For simplicity's sake this relies on the UPLOAD_DIR config key provided
+ # when using the FileSystem PHOTO_STORAGE_BACKEND. Should your FMS site not
+ # be using this storage backend, you must ensure that UPLOAD_DIR is set
+ # in order for general enquiries uploads to work.
+ my $cfg = FixMyStreet->config('PHOTO_STORAGE_OPTIONS');
+ my $dir = $cfg ? $cfg->{UPLOAD_DIR} : FixMyStreet->config('UPLOAD_DIR');
+ $dir = path($dir, "enquiry_files")->absolute(FixMyStreet->path_to());
+ $dir->mkpath;
+
+ my $files = $c->session->{enquiry_files} || {};
+ foreach ($c->req->upload) {
+ my $upload = $c->req->upload($_);
+ if ($upload->type !~ /^image/) {
+ # It's not a photo so remove it before /photo/process_photo rejects it
+ delete $c->req->uploads->{$_};
+
+ # For each file, copy it into place in a subdir of PHOTO_STORAGE_OPTIONS.UPLOAD_DIR
+ FixMyStreet::PhotoStorage::base64_decode_upload($c, $upload);
+ # Hash each file to get its filename, but preserve the file extension
+ # so content-type is correct when POSTing to Open311.
+ my ($p, $n, $ext) = fileparse($upload->filename, qr/\.[^.]*/);
+ my $key = sha1_hex($upload->slurp) . $ext;
+ my $out = path($dir, $key);
+ unless (copy($upload->tempname, $out)) {
+ $c->log->info('Couldn\'t copy temp file to destination: ' . $!);
+ $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again.");
+ return;
+ }
+ # Then store the file hashes in report->extra along with the original filenames
+ $files->{$key} = $upload->raw_basename;
+ }
+ }
+ $c->session->{enquiry_files} = $files;
+ $c->stash->{report}->set_extra_metadata(enquiry_files => $files);
+}
+
+1;
diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm
index bd60f8570..ad6c9ba98 100644
--- a/perllib/FixMyStreet/App/Controller/Dashboard.pm
+++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm
@@ -70,15 +70,20 @@ sub check_page_allowed : Private {
$c->detach( '/auth/redirect' ) unless $c->user_exists;
- $c->detach( '/page_error_404_not_found' )
- unless $c->user->from_body || $c->user->is_superuser;
+ my $cobrand_body = $c->cobrand->can('council_area_id') ? $c->cobrand->body : undef;
- my $body = $c->user->from_body;
- if (!$body && $c->get_param('body')) {
- # Must be a superuser, so allow query parameter if given
- $body = $c->model('DB::Body')->find({ id => $c->get_param('body') });
+ my $body;
+ if ($c->user->is_superuser) {
+ if ($c->get_param('body')) {
+ $body = $c->model('DB::Body')->find({ id => $c->get_param('body') });
+ } else {
+ $body = $cobrand_body;
+ }
+ } elsif ($c->user->from_body && (!$cobrand_body || $cobrand_body->id == $c->user->from_body->id)) {
+ $body = $c->user->from_body;
+ } else {
+ $c->detach( '/page_error_404_not_found' )
}
-
return $body;
}
@@ -104,6 +109,7 @@ sub index : Path : Args(0) {
$c->forward('/admin/fetch_contacts');
$c->stash->{contacts} = [ $c->stash->{contacts}->all ];
+ $c->forward('/report/stash_category_groups', [ $c->stash->{contacts}, 0 ]);
# See if we've had anything from the body dropdowns
$c->stash->{category} = $c->get_param('category');
@@ -322,6 +328,7 @@ sub export_as_csv_updates : Private {
objects => $c->stash->{objects_rs}->search_rs({}, {
order_by => ['me.confirmed', 'me.id'],
'+columns' => ['problem.bodies_str'],
+ cursor_page_size => 1000,
}),
headers => [
'Report ID', 'Update ID', 'Date', 'Status', 'Problem state',
@@ -342,8 +349,10 @@ sub export_as_csv : Private {
my $csv = $c->stash->{csv} = {
objects => $c->stash->{objects_rs}->search_rs({}, {
- prefetch => 'comments',
+ join => 'comments',
+ '+columns' => ['comments.problem_state', 'comments.state', 'comments.confirmed', 'comments.mark_fixed'],
order_by => ['me.confirmed', 'me.id'],
+ cursor_page_size => 1000,
}),
headers => [
'Report ID',
@@ -487,9 +496,6 @@ sub generate_csv : Private {
}
$csv->print($c->response, [
- map {
- $_ = encode('UTF-8', $_) if $_;
- }
@{$hashref}{
@{$c->stash->{csv}->{columns}}
},
@@ -497,6 +503,114 @@ sub generate_csv : Private {
}
}
+sub heatmap : Local : Args(0) {
+ my ($self, $c) = @_;
+
+ my $body = $c->stash->{body} = $c->forward('check_page_allowed');
+ $c->detach( '/page_error_404_not_found' )
+ unless $body && $c->cobrand->feature('heatmap');
+
+ $c->stash->{page} = 'reports'; # So the map knows to make clickable pins
+
+ my @wards = $c->get_param_list('wards', 1);
+ $c->forward('/reports/ward_check', [ @wards ]) if @wards;
+ $c->forward('/reports/stash_report_filter_status');
+ $c->forward('/reports/stash_report_sort', [ $c->cobrand->reports_ordering ]); # Not actually used
+ my $parameters = $c->forward( '/reports/load_problems_parameters');
+
+ my $where = $parameters->{where};
+ my $filter = $parameters->{filter};
+ delete $filter->{rows};
+
+ $c->forward('heatmap_filters', [ $where ]);
+
+ # Load the relevant stuff for the sidebar as well
+ my $problems = $c->cobrand->problems;
+ $problems = $problems->to_body($body);
+ $problems = $problems->search($where, $filter);
+
+ $c->forward('heatmap_sidebar', [ $problems, $where ]);
+
+ if ($c->get_param('ajax')) {
+ my @pins;
+ while ( my $problem = $problems->next ) {
+ push @pins, $problem->pin_data($c, 'reports');
+ }
+ $c->stash->{pins} = \@pins;
+ $c->detach('/reports/ajax', [ 'dashboard/heatmap-list.html' ]);
+ }
+
+ my $children = $c->stash->{body}->first_area_children;
+ $c->stash->{children} = $children;
+ $c->stash->{ward_hash} = { map { $_->{id} => 1 } @{$c->stash->{wards}} } if $c->stash->{wards};
+
+ $c->forward('/reports/setup_categories_and_map');
+}
+
+sub heatmap_filters :Private {
+ my ($self, $c, $where) = @_;
+
+ # Wards
+ if ($c->user_exists) {
+ my @areas = @{$c->user->area_ids || []};
+ # Want to get everything if nothing given in an ajax call
+ if (!$c->stash->{wards} && @areas) {
+ $c->stash->{wards} = [ map { { id => $_ } } @areas ];
+ $where->{areas} = [
+ map { { 'like', '%,' . $_ . ',%' } } @areas
+ ];
+ }
+ }
+
+ # Date range
+ my $start_default = DateTime->today(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(months => 1);
+ $c->stash->{start_date} = $c->get_param('start_date') || $start_default->strftime('%Y-%m-%d');
+ $c->stash->{end_date} = $c->get_param('end_date');
+
+ my $range = FixMyStreet::DateRange->new(
+ start_date => $c->stash->{start_date},
+ start_default => $start_default,
+ end_date => $c->stash->{end_date},
+ formatter => $c->model('DB')->storage->datetime_parser,
+ );
+ $where->{'me.confirmed'} = $range->sql;
+}
+
+sub heatmap_sidebar :Private {
+ my ($self, $c, $problems, $where) = @_;
+
+ $c->stash->{five_newest} = [ $problems->search(undef, {
+ rows => 5,
+ order_by => { -desc => 'confirmed' },
+ })->all ];
+
+ $c->stash->{ten_oldest} = [ $problems->search({
+ 'me.state' => [ FixMyStreet::DB::Result::Problem->open_states() ],
+ }, {
+ rows => 10,
+ order_by => 'lastupdate',
+ })->all ];
+
+ my $params = { map { my $n = $_; s/me\./problem\./; $_ => $where->{$n} } keys %$where };
+ my $body = $c->stash->{body};
+
+ my @user;
+ push @user, $c->user->id if $c->user_exists;
+ push @user, $body->comment_user_id if $body->comment_user_id;
+ $params->{'me.user_id'} = { -not_in => \@user } if @user;
+
+ my @c = $c->model('DB::Comment')->to_body($body)->search({
+ %$params,
+ 'me.state' => 'confirmed',
+ }, {
+ columns => 'problem_id',
+ group_by => 'problem_id',
+ order_by => { -desc => \'max(me.confirmed)' },
+ rows => 5,
+ })->all;
+ $c->stash->{five_commented} = [ map { $_->problem } @c ];
+}
+
=head1 AUTHOR
Matthew Somerville
diff --git a/perllib/FixMyStreet/App/Controller/Develop.pm b/perllib/FixMyStreet/App/Controller/Develop.pm
index ae7122fa1..6a1c10b22 100755
--- a/perllib/FixMyStreet/App/Controller/Develop.pm
+++ b/perllib/FixMyStreet/App/Controller/Develop.pm
@@ -115,12 +115,34 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
}
} elsif ($template eq 'questionnaire') {
$vars->{created} = 'N weeks';
+ } elsif ($template eq 'contact') {
+ $vars->{problem} = $c->model('DB::Problem')->search(undef, { rows => 1 } )->first;
+ $vars->{subject} = 'Please remove my details';
+ $vars->{message} = 'I accidentally put my phone number, address, mothers maiden name, and facebook password in my most recent report!! Please remove it!!';
+ $vars->{form_name} = $c->user->name;
+ $vars->{em} = $c->user->email;
+ $vars->{host} = $c->req->header('HOST');
+ $vars->{ip} = $c->req->address;
+ $vars->{user_agent} = $c->req->user_agent;
+ $vars->{complaint} = sprintf(
+ "Complaint about report %d",
+ $vars->{problem}->id,
+ );
+ $vars->{problem_url} = $c->cobrand->base_url() . '/report/' . $vars->{problem}->id;
+ $vars->{admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $vars->{problem}->id;
+ $vars->{user_admin_url} = $c->cobrand->admin_base_url . '/users/' . $c->user->id;
+ $vars->{user_reports_admin_url} = $c->cobrand->admin_base_url . '/reports?search=' . $c->user->email;
+ my $user_latest_problem = $c->user->latest_visible_problem();
+ if ( $user_latest_problem ) {
+ $vars->{user_latest_report_admin_url} = $c->cobrand->admin_base_url . '/report_edit/' . $user_latest_problem->id;
+ }
}
my $email = $c->construct_email("$template.txt", $vars);
# Look through the Email::MIME email for the text/html part, and any inline
# images. Turn the images into data: URIs.
+ my $text = '';
my $html = '';
my %images;
$email->walk_parts(sub {
@@ -130,6 +152,8 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
(my $cid = $part->header('Content-ID')) =~ s/[<>]//g;
(my $ct = $part->content_type) =~ s/;.*//;
$images{$cid} = "$ct;base64," . $part->body_raw;
+ } elsif ($part->content_type =~ m[text/plain]i) {
+ $text = $part->body_str;
} elsif ($part->content_type =~ m[text/html]i) {
$html = $part->body_str;
}
@@ -139,7 +163,12 @@ sub email_previewer : Path('/_dev/email') : Args(1) {
$html =~ s/cid:([^"]*)/data:$images{$1}/g;
}
- $c->response->body($html);
+ if ($c->get_param('text')) {
+ $c->response->header(Content_type => 'text/plain');
+ $c->response->body($text);
+ } else {
+ $c->response->body($html);
+ }
}
=item problem_confirm_previewer
diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm
index 8d5b0b147..416fb942a 100644
--- a/perllib/FixMyStreet/App/Controller/Location.pm
+++ b/perllib/FixMyStreet/App/Controller/Location.pm
@@ -6,6 +6,7 @@ BEGIN {extends 'Catalyst::Controller'; }
use Encode;
use FixMyStreet::Geocode;
+use Try::Tiny;
use Utils;
=head1 NAME
@@ -107,6 +108,25 @@ sub determine_location_from_pc : Private {
# pass errors back to the template
$c->stash->{location_error_pc_lookup} = 1;
$c->stash->{location_error} = $error;
+
+ # Log failure in a log db
+ try {
+ my $dbfile = FixMyStreet->path_to('../data/analytics.sqlite');
+ my $db = DBI->connect("dbi:SQLite:dbname=$dbfile", undef, undef, { PrintError => 0 }) or die "$DBI::errstr\n";
+ my $sth = $db->prepare("INSERT INTO location_searches_with_no_results
+ (datetime, cobrand, geocoder, url, user_input)
+ VALUES (?, ?, ?, ?, ?)") or die $db->errstr . "\n";
+ my $rv = $sth->execute(
+ POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime(time())),
+ $c->cobrand->moniker,
+ $c->cobrand->get_geocoder(),
+ $c->stash->{geocoder_url},
+ $pc,
+ );
+ } catch {
+ $c->log->debug("Unable to log to analytics.sqlite: $_");
+ };
+
return;
}
diff --git a/perllib/FixMyStreet/App/Controller/Moderate.pm b/perllib/FixMyStreet/App/Controller/Moderate.pm
index 22869d531..f4143f0b4 100644
--- a/perllib/FixMyStreet/App/Controller/Moderate.pm
+++ b/perllib/FixMyStreet/App/Controller/Moderate.pm
@@ -214,7 +214,7 @@ sub report_moderate_hide : Private {
if ($c->get_param('problem_hide')) {
$problem->update({ state => 'hidden' });
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
$c->res->redirect( '/' ); # Go directly to front-page
$c->detach( 'report_moderate_audit', ['hide'] ); # break chain here.
@@ -263,8 +263,8 @@ sub moderate_boolean : Private {
if ($new != $old) {
if ($thing eq 'photo') {
- $object->$thing($new ? $original : undef);
$object->get_photoset->delete_cached;
+ $object->$thing($new ? $original : undef);
} else {
$object->$thing($new);
}
@@ -298,7 +298,7 @@ sub moderate_location : Private {
my $problem = $c->stash->{problem};
- my $moved = $c->forward('/admin/report_edit_location', [ $problem ]);
+ my $moved = $c->forward('/admin/reports/edit_location', [ $problem ]);
if (!$moved) {
# New lat/lon isn't valid, show an error
$c->stash->{moderate_errors} ||= [];
@@ -315,11 +315,11 @@ sub moderate_category : Private {
return unless $c->get_param('category');
# The admin category editing needs to know all the categories etc
- $c->forward('/admin/categories_for_point');
+ $c->forward('/admin/reports/categories_for_point');
my $problem = $c->stash->{problem};
- my $changed = $c->forward( '/admin/report_edit_category', [ $problem, 1 ] );
+ my $changed = $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] );
# It might need to set_report_extras in future
if ($changed) {
return 'category';
diff --git a/perllib/FixMyStreet/App/Controller/My.pm b/perllib/FixMyStreet/App/Controller/My.pm
index ed890ad82..3328caac0 100644
--- a/perllib/FixMyStreet/App/Controller/My.pm
+++ b/perllib/FixMyStreet/App/Controller/My.pm
@@ -45,6 +45,34 @@ sub my : Path : Args(0) {
$c->forward('setup_page_data');
}
+
+=head2 inspector_redirect
+
+A convenience redirect to the /reports/ page pre-filtered to the
+inspector's body, areas & categories.
+
+=cut
+
+sub inspector_redirect : Local : Args(0) {
+ my ( $self, $c ) = @_;
+
+ my $categories = $c->user->categories_string;
+ my $area_ids = $c->user->area_ids || [];
+ my $body = $c->user->from_body;
+
+ $c->detach('/page_error_404_not_found') unless $body && ($categories || @$area_ids);
+
+ if (@$area_ids) {
+ my $ids_string = join ",", @$area_ids;
+ my $areas = mySociety::MaPit::call('areas', [ $ids_string ]);
+ $c->stash->{wards} = [ values %$areas ];
+ }
+
+ $c->stash->{body} = $body;
+ $c->set_param('filter_category', $categories) if $categories;
+ $c->detach('/reports/redirect_body');
+}
+
sub planned : Local : Args(0) {
my ( $self, $c ) = @_;
@@ -161,11 +189,12 @@ sub setup_page_data : Private {
my @categories = $c->stash->{problems_rs}->search({
state => [ FixMyStreet::DB::Result::Problem->visible_states() ],
}, {
- columns => [ 'category' ],
+ columns => [ 'category', 'bodies_str', 'extra' ],
distinct => 1,
order_by => [ 'category' ],
} )->all;
$c->stash->{filter_categories} = \@categories;
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $pins = $c->stash->{pins};
FixMyStreet::Map::display_map(
diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm
index d50d0d03f..adb3de14d 100644
--- a/perllib/FixMyStreet/App/Controller/Offline.pm
+++ b/perllib/FixMyStreet/App/Controller/Offline.pm
@@ -1,5 +1,9 @@
package FixMyStreet::App::Controller::Offline;
+
+use Image::Size;
+use JSON::MaybeXS;
use Moose;
+use Path::Tiny;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
@@ -10,35 +14,108 @@ FixMyStreet::App::Controller::Offline - Catalyst Controller
=head1 DESCRIPTION
-Offline pages Catalyst Controller.
+Offline pages Catalyst Controller - service worker handling
=head1 METHODS
=cut
-sub have_appcache : Private {
+sub service_worker : Path("/service-worker.js") {
my ($self, $c) = @_;
- return $c->user_exists && $c->user->has_body_permission_to('planned_reports')
- && !($c->user->is_superuser && FixMyStreet->staging_flag('enable_appcache', 0));
+ $c->res->content_type('application/javascript');
}
-sub manifest : Path("/offline/appcache.manifest") {
+sub fallback : Local {
my ($self, $c) = @_;
- unless ($c->forward('have_appcache')) {
- $c->response->status(404);
- $c->response->body('NOT FOUND');
- }
- $c->res->content_type('text/cache-manifest; charset=utf-8');
- $c->res->header(Cache_Control => 'no-cache, no-store');
}
-sub appcache : Path("/offline/appcache") {
+sub manifest: Path("/.well-known/manifest.webmanifest") {
my ($self, $c) = @_;
- $c->detach('/page_error_404_not_found', []) if keys %{$c->req->params};
- unless ($c->forward('have_appcache')) {
- $c->response->status(404);
- $c->response->body('NOT FOUND');
+ $c->res->content_type('application/manifest+json');
+
+ my $data = {
+ name => $c->stash->{manifest_theme}->{name},
+ short_name => $c->stash->{manifest_theme}->{short_name},
+ background_color => $c->stash->{manifest_theme}->{background_colour},
+ theme_color => $c->stash->{manifest_theme}->{theme_colour},
+ icons => $c->stash->{manifest_theme}->{icons},
+ lang => $c->stash->{lang_code},
+ display => "minimal-ui",
+ start_url => "/?pwa",
+ scope => "/",
+ };
+ if ($c->cobrand->can('manifest')) {
+ $data = { %$data, %{$c->cobrand->manifest} };
+ }
+
+ my $json = encode_json($data);
+ $c->res->body($json);
+}
+
+sub _stash_manifest_theme : Private {
+ my ($self, $c, $cobrand) = @_;
+
+ $c->stash->{manifest_theme} = $c->forward('_find_manifest_theme', [ $cobrand ]);
+}
+
+sub _find_manifest_theme : Private {
+ my ($self, $c, $cobrand, $ignore_cache_and_defaults) = @_;
+
+ my $key = "manifest_theme:$cobrand";
+ # ignore_cache_and_defaults is only used in the admin, so no harm bypassing cache
+ my $manifest_theme = $ignore_cache_and_defaults ? undef : Memcached::get($key);
+
+ unless ( $manifest_theme ) {
+ my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $cobrand });
+ unless ( $theme ) {
+ $theme = $c->model('DB::ManifestTheme')->new({
+ name => $c->stash->{site_name},
+ short_name => $c->stash->{site_name},
+ background_colour => '#ffffff',
+ theme_colour => '#ffd000',
+ });
+ }
+
+ my @icons;
+ my $uri = '/theme/' . $cobrand;
+ my $theme_path = path(FixMyStreet->path_to('web' . $uri));
+ $theme_path->visit(
+ sub {
+ my ($x, $y, $typ) = Image::Size::imgsize($_->stringify);
+ push @icons, {
+ src => join('/', $uri, $_->basename),
+ sizes => join('x', $x, $y),
+ type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '',
+ };
+ }
+ );
+
+ unless (@icons || $ignore_cache_and_defaults) {
+ push @icons,
+ { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" },
+ { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" };
+ }
+
+ $manifest_theme = {
+ icons => \@icons,
+ background_colour => $theme->background_colour,
+ theme_colour => $theme->theme_colour,
+ name => $theme->name,
+ short_name => $theme->short_name,
+ };
+
+ unless ($ignore_cache_and_defaults) {
+ Memcached::set($key, $manifest_theme);
+ }
}
+
+ return $manifest_theme;
+}
+
+sub _clear_manifest_theme_cache : Private {
+ my ($self, $c, $cobrand ) = @_;
+
+ Memcached::delete("manifest_theme:$cobrand");
}
__PACKAGE__->meta->make_immutable;
diff --git a/perllib/FixMyStreet/App/Controller/Open311.pm b/perllib/FixMyStreet/App/Controller/Open311.pm
index 841330e92..b4b5d5e3a 100644
--- a/perllib/FixMyStreet/App/Controller/Open311.pm
+++ b/perllib/FixMyStreet/App/Controller/Open311.pm
@@ -111,8 +111,6 @@ sub get_discovery : Private {
{
'contact' => ["Send email to $contact_email."],
'changeset' => [$prod_changeset],
- # XXX rewrite to match
- 'key_service' => ["Read access is open to all according to our \u003Ca href='/open_data' target='_blank'\u003Eopen data license\u003C/a\u003E. For write access either: 1. return the 'guid' cookie on each call (unique to each client) or 2. use an api key from a user account which can be generated here: http://seeclickfix.com/register The unversioned url will always point to the latest supported version."],
'max_requests' => [ $c->config->{OPEN311_LIMIT} || 1000 ],
'endpoints' => [
{
@@ -195,9 +193,7 @@ sub get_services : Private {
);
}
$c->forward( 'format_output', [ {
- 'services' => [ {
- 'service' => \@services
- } ]
+ 'services' => \@services
} ] );
}
@@ -291,9 +287,7 @@ sub output_requests : Private {
}
$c->forward( 'format_output', [ {
- 'requests' => [ {
- 'request' => \@problemlist
- } ]
+ service_requests => \@problemlist
} ] );
}
@@ -429,7 +423,21 @@ sub format_output : Private {
$c->res->body( encode_json($hashref) );
} elsif ('xml' eq $format) {
$c->res->content_type('application/xml; charset=utf-8');
- $c->res->body( XMLout($hashref, RootName => undef, NoAttr => 1 ) );
+ my $group_tags = {
+ services => 'service',
+ attributes => 'attribute',
+ values => 'value',
+ service_requests => 'request',
+ errors => 'error',
+ service_request_updates => 'request_update',
+ };
+ $c->res->body( XMLout($hashref,
+ KeyAttr => {},
+ GroupTags => $group_tags,
+ SuppressEmpty => undef,
+ RootName => undef,
+ NoAttr => 1,
+ ) );
} else {
$c->detach( 'error', [
sprintf(_('Invalid format %s specified.'), $format)
diff --git a/perllib/FixMyStreet/App/Controller/Open311/Updates.pm b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm
new file mode 100644
index 000000000..105400a8a
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Open311/Updates.pm
@@ -0,0 +1,88 @@
+package FixMyStreet::App::Controller::Open311::Updates;
+
+use utf8;
+use Moose;
+use namespace::autoclean;
+use Open311;
+use Open311::GetServiceRequestUpdates;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Open311::Updates - Catalyst Controller
+
+=head1 DESCRIPTION
+
+=head1 METHODS
+
+=cut
+
+sub receive : Regex('^open311/v2/servicerequestupdates.(xml|json)$') : Args(0) {
+ my ( $self, $c ) = @_;
+ $c->stash->{format} = $c->req->captures->[0];
+
+ $c->detach('bad_request', [ 'POST' ]) unless $c->req->method eq 'POST';
+
+ my $body;
+ if ($c->cobrand->can('council_area_id')) {
+ $body = $c->cobrand->body;
+ } else {
+ $body = $c->model('DB::Body')->find({ id => $c->get_param('jurisdiction_id') });
+ }
+ $c->detach('bad_request', ['jurisdiction_id']) unless $body;
+ my $user = $body->comment_user;
+
+ my $key = $c->get_param('api_key') || '';
+ my $token = $c->cobrand->feature('open311_token') || '';
+ $c->detach('bad_request', [ 'api_key' ]) unless $key && $key eq $token;
+
+ my $request = {
+ media_url => $c->get_param('media_url'),
+ external_status_code => $c->get_param('external_status_code'),
+ };
+ foreach (qw(service_request_id update_id updated_datetime status description)) {
+ $request->{$_} = $c->get_param($_) || $c->detach('bad_request', [ $_ ]);
+ }
+
+ my %open311_conf = (
+ endpoint => $body->endpoint,
+ api_key => $body->api_key,
+ jurisdiction => $body->jurisdiction,
+ extended_statuses => $body->send_extended_statuses,
+ );
+
+ my $cobrand = $body->get_cobrand_handler;
+ $cobrand->call_hook(open311_config_updates => \%open311_conf)
+ if $cobrand;
+
+ my $open311 = Open311->new(%open311_conf);
+ my $updates = Open311::GetServiceRequestUpdates->new(
+ system_user => $user,
+ current_open311 => $open311,
+ current_body => $body,
+ );
+
+ my $p = $updates->find_problem($request);
+ $c->detach('bad_request', [ 'not found' ]) unless $p;
+
+ my $comment = $p->comments->search( { external_id => $request->{update_id} } )->first;
+ $c->detach('bad_request', [ 'already exists' ]) if $comment;
+
+ $comment = $updates->process_update($request, $p);
+
+ my $data = { service_request_updates => { update_id => $comment->id } };
+
+ $c->forward('/open311/format_output', [ $data ]);
+}
+
+sub bad_request : Private {
+ my ($self, $c, $comment) = @_;
+ $c->response->status(400);
+ $c->forward('/open311/format_output', [ { errors => { code => 400, description => "Bad request: $comment" } } ]);
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Photo.pm b/perllib/FixMyStreet/App/Controller/Photo.pm
index 7b536a292..3408d5e35 100644
--- a/perllib/FixMyStreet/App/Controller/Photo.pm
+++ b/perllib/FixMyStreet/App/Controller/Photo.pm
@@ -39,10 +39,11 @@ sub during :LocalRegex('^(temp|fulltemp)\.([0-9a-f]{40}\.(?:jpeg|png|gif|tiff))$
$size = $size eq 'temp' ? 'default' : 'full';
my $photo = $photoset->get_image_data(size => $size, default => $c->cobrand->default_photo_resize);
+ $c->stash->{non_public} = 0;
$c->forward( 'output', [ $photo ] );
}
-sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|png|gif|tiff)$') {
+sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp|og))?\.(?:jpeg|png|gif|tiff)$') {
my ( $self, $c ) = @_;
my ( $is_update, $id, $photo_number, $size ) = @{ $c->req->captures };
@@ -51,11 +52,12 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|
my $item;
if ( $is_update ) {
- ($item) = $c->model('DB::Comment')->search( {
- id => $id,
- state => 'confirmed',
- photo => { '!=', undef },
- } );
+ ($item) = $c->cobrand->updates->search( {
+ 'me.id' => $id,
+ 'me.state' => 'confirmed',
+ 'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ],
+ 'me.photo' => { '!=', undef },
+ }, { prefetch => 'problem' });
} else {
($item) = $c->cobrand->problems->search( {
id => $id,
@@ -68,6 +70,19 @@ sub index :LocalRegex('^(c/)?([1-9]\d*)(?:\.(\d+))?(?:\.(full|tn|fp))?\.(?:jpeg|
$c->detach( 'no_photo' ) unless $c->cobrand->allow_photo_display($item, $photo_number); # Should only be for reports, not updates
+ my $problem = $is_update ? $item->problem : $item;
+ $c->stash->{non_public} = $problem->non_public;
+
+ if ($c->stash->{non_public}) {
+ my $body_ids = $problem->bodies_str_ids;
+ # Check permission
+ $c->detach('no_photo') unless $c->user_exists;
+ $c->detach('no_photo') unless $c->user->is_superuser
+ || $c->user->id == $problem->user->id
+ || $c->user->has_permission_to('report_inspect', $body_ids)
+ || $c->user->has_permission_to('report_mark_private', $body_ids);
+ }
+
my $photo;
$photo = $item->get_photoset
->get_image_data( num => $photo_number, size => $size, default => $c->cobrand->default_photo_resize )
@@ -80,10 +95,12 @@ sub output : Private {
my ( $self, $c, $photo ) = @_;
# Save to file
- path(FixMyStreet->path_to('web', 'photo', 'c'))->mkpath;
- my $out = FixMyStreet->path_to('web', $c->req->path);
- my $symlink_exists = $photo->{symlink} ? symlink($photo->{symlink}, $out) : undef;
- path($out)->spew_raw($photo->{data}) unless $symlink_exists;
+ if (!FixMyStreet->config('LOGIN_REQUIRED') && !$c->stash->{non_public}) {
+ path(FixMyStreet->path_to('web', 'photo', 'c'))->mkpath;
+ my $out = FixMyStreet->path_to('web', $c->req->path);
+ my $symlink_exists = $photo->{symlink} ? symlink($photo->{symlink}, $out) : undef;
+ path($out)->spew_raw($photo->{data}) unless $symlink_exists;
+ }
$c->res->content_type( $photo->{content_type} );
$c->res->body( $photo->{data} );
diff --git a/perllib/FixMyStreet/App/Controller/Questionnaire.pm b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
index d2b0bf3f4..ab6117ae4 100755
--- a/perllib/FixMyStreet/App/Controller/Questionnaire.pm
+++ b/perllib/FixMyStreet/App/Controller/Questionnaire.pm
@@ -290,9 +290,9 @@ sub display : Private {
my $problem = $c->stash->{questionnaire}->problem;
- $c->stash->{updates} = [ $c->model('DB::Comment')->search(
- { problem_id => $problem->id, state => 'confirmed' },
- { order_by => 'confirmed' }
+ $c->stash->{updates} = [ $c->cobrand->updates->search(
+ { problem_id => $problem->id, "me.state" => 'confirmed' },
+ { order_by => 'me.confirmed' }
)->all ];
$c->stash->{page} = 'questionnaire';
diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm
index 7f798f4f4..72f96013a 100644
--- a/perllib/FixMyStreet/App/Controller/Report.pm
+++ b/perllib/FixMyStreet/App/Controller/Report.pm
@@ -1,5 +1,6 @@
package FixMyStreet::App::Controller::Report;
+use utf8;
use Moose;
use namespace::autoclean;
use JSON::MaybeXS;
@@ -85,11 +86,15 @@ sub display :PathPart('') :Chained('id') :Args(0) {
$c->forward( 'format_problem_for_display' );
my $permissions = $c->stash->{_permissions} ||= $c->forward( 'check_has_permission_to',
- [ qw/report_inspect report_edit_category report_edit_priority report_mark_private/ ] );
+ [ qw/report_inspect report_edit_category report_edit_priority report_mark_private triage/ ] );
if (any { $_ } values %$permissions) {
$c->stash->{template} = 'report/inspect.html';
$c->forward('inspect');
}
+
+ if ($c->user_exists && $c->user->has_permission_to(contribute_as_another_user => $c->stash->{problem}->bodies_str_ids)) {
+ $c->stash->{email} = $c->user->email;
+ }
}
sub moderate_report :PathPart('moderate') :Chained('id') :Args(0) {
@@ -155,10 +160,20 @@ sub load_problem_or_display_error : Private {
$c->stash->{problem} = $problem;
my $permissions = $c->stash->{_permissions} = $c->forward( 'check_has_permission_to',
[ qw/report_inspect report_edit_category report_edit_priority report_mark_private / ] );
- if ( !$c->user || ($c->user->id != $problem->user->id && !($permissions->{report_inspect} || $permissions->{report_mark_private})) ) {
+
+ # If someone has clicked a unique token link in an email to them
+ my $from_email = $c->sessionid && $c->flash->{alert_to_reporter} && $c->flash->{alert_to_reporter} == $problem->id;
+
+ my $allowed = 0;
+ $allowed = 1 if $from_email;
+ $allowed = 1 if $c->user_exists && $c->user->id == $problem->user->id;
+ $allowed = 1 if $permissions->{report_inspect} || $permissions->{report_mark_private};
+
+ unless ($allowed) {
+ my $url = '/auth?r=report/' . $problem->id;
$c->detach(
'/page_error_403_access_denied',
- [ sprintf(_('That report cannot be viewed on %s.'), $c->stash->{site_name}) ]
+ [ sprintf(_('Sorry, you don’t have permission to do that. If you are the problem reporter, or a member of staff, please <a href="%s">sign in</a> to view this report.'), $url) ]
);
}
}
@@ -181,9 +196,9 @@ sub load_problem_or_display_error : Private {
sub load_updates : Private {
my ( $self, $c ) = @_;
- my $updates = $c->model('DB::Comment')->search(
- { problem_id => $c->stash->{problem}->id, state => 'confirmed' },
- { order_by => [ 'confirmed', 'id' ] }
+ my $updates = $c->cobrand->updates->search(
+ { problem_id => $c->stash->{problem}->id, "me.state" => 'confirmed' },
+ { order_by => [ 'me.confirmed', 'me.id' ] }
);
my $questionnaires_still_open = $c->model('DB::Questionnaire')->search(
@@ -293,7 +308,8 @@ sub format_problem_for_display : Private {
delete $report_hashref->{created};
delete $report_hashref->{confirmed};
- my $content = encode_json(
+ my $json = JSON::MaybeXS->new( convert_blessed => 1, utf8 => 1 );
+ my $content = $json->encode(
{
report => $report_hashref,
updates => $c->cobrand->updates_as_hashref( $problem, $c ),
@@ -354,8 +370,6 @@ sub delete :Chained('id') :Args(0) {
$p->lastupdate( \'current_timestamp' );
$p->update;
- $p->user->update_reputation(-1);
-
$c->model('DB::AdminLog')->create( {
user => $c->user->obj,
admin_user => $c->user->from_body->name,
@@ -372,13 +386,19 @@ sub inspect : Private {
my $problem = $c->stash->{problem};
my $permissions = $c->stash->{_permissions};
- $c->forward('/admin/categories_for_point');
+ $c->forward('/admin/reports/categories_for_point');
$c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @{ $c->stash->{problem}->get_extra_fields() } };
- if ($c->cobrand->can('council_area_id')) {
- my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}});
+ if ($c->cobrand->can('body')) {
+ my $priorities_by_category = FixMyStreet::App->model('DB::ResponsePriority')->by_categories(
+ $c->stash->{contacts},
+ body_id => $c->cobrand->body->id
+ );
$c->stash->{priorities_by_category} = $priorities_by_category;
- my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories($c->cobrand->council_area_id, @{$c->stash->{contacts}});
+ my $templates_by_category = FixMyStreet::App->model('DB::ResponseTemplate')->by_categories(
+ $c->stash->{contacts},
+ body_id => $c->cobrand->body->id
+ );
$c->stash->{templates_by_category} = $templates_by_category;
}
@@ -394,12 +414,18 @@ sub inspect : Private {
$c->stash->{max_detailed_info_length} = $c->cobrand->max_detailed_info_length;
- if ( $c->get_param('save') ) {
+ if ( $c->get_param('triage') ) {
+ $c->forward('/auth/check_csrf_token');
+ $c->forward('/admin/triage/update');
+ my $redirect_uri = $c->uri_for( '/admin/triage' );
+ $c->log->debug( "Redirecting to: " . $redirect_uri );
+ $c->res->redirect( $redirect_uri );
+ }
+ elsif ( $c->get_param('save') ) {
$c->forward('/auth/check_csrf_token');
my $valid = 1;
my $update_text = '';
- my $reputation_change = 0;
my %update_params = ();
if ($permissions->{report_inspect}) {
@@ -435,7 +461,7 @@ sub inspect : Private {
$problem->confirmed( \'current_timestamp' );
}
if ( $problem->state eq 'hidden' ) {
- $problem->get_photoset->delete_cached;
+ $problem->get_photoset->delete_cached(plus_updates => 1);
}
if ( $problem->state eq 'duplicate') {
if (my $duplicate_of = $c->get_param('duplicate_of')) {
@@ -454,8 +480,6 @@ sub inspect : Private {
$update_params{problem_state} = $problem->state;
my $state = $problem->state;
- $reputation_change = 1 if $c->cobrand->reputation_increment_states->{$state};
- $reputation_change = -1 if $c->cobrand->reputation_decrement_states->{$state};
# If an inspector has changed the state, subscribe them to
# updates
@@ -466,19 +490,14 @@ sub inspect : Private {
};
$c->user->create_alert($problem->id, $options);
}
-
- # If the state has been changed to action scheduled and they've said
- # they want to raise a defect, consider the report to be inspected.
- if ($problem->state eq 'action scheduled' && $c->get_param('raise_defect') && !$problem->get_extra_metadata('inspected')) {
- $update_params{extra} = { 'defect_raised' => 1 };
- $problem->set_extra_metadata( inspected => 1 );
- $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'inspected' ] );
- }
}
$problem->non_public($c->get_param('non_public') ? 1 : 0);
+ if ($problem->non_public) {
+ $problem->get_photoset->delete_cached(plus_updates => 1);
+ }
- if ( !$c->forward( '/admin/report_edit_location', [ $problem ] ) ) {
+ if ( !$c->forward( '/admin/reports/edit_location', [ $problem ] ) ) {
# New lat/lon isn't valid, show an error
$valid = 0;
$c->stash->{errors} ||= [];
@@ -486,10 +505,11 @@ sub inspect : Private {
}
if ($permissions->{report_inspect} || $permissions->{report_edit_category}) {
- $c->forward( '/admin/report_edit_category', [ $problem, 1 ] );
+ $c->forward( '/admin/reports/edit_category', [ $problem, 1 ] );
if ($c->stash->{update_text}) {
- $update_text .= "\n\n" . $c->stash->{update_text};
+ $update_text .= "\n\n" if $update_text;
+ $update_text .= $c->stash->{update_text};
}
# The new category might require extra metadata (e.g. pothole size), so
@@ -511,22 +531,12 @@ sub inspect : Private {
}
}
- if ($permissions->{report_inspect}) {
- if ( $c->get_param('defect_type') ) {
- $problem->defect_type($problem->defect_types->find($c->get_param('defect_type')));
- } else {
- $problem->defect_type(undef);
- }
- }
-
$c->cobrand->call_hook(report_inspect_update_extra => $problem);
if ($valid) {
- if ( $reputation_change != 0 ) {
- $problem->user->update_reputation($reputation_change);
- }
$problem->lastupdate( \'current_timestamp' );
$problem->update;
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', 'edit' ] );
if ($update_text || %update_params) {
my $timestamp = \'current_timestamp';
if (my $saved_at = $c->get_param('saved_at')) {
@@ -590,7 +600,13 @@ sub inspect : Private {
sub map :Chained('id') :Args(0) {
my ($self, $c) = @_;
- my $image = $c->stash->{problem}->static_map;
+ my %params;
+ if ( $c->get_param('inline_duplicate') ) {
+ $params{full_size} = 1;
+ $params{zoom} = 5;
+ }
+
+ my $image = $c->stash->{problem}->static_map(%params);
$c->res->content_type($image->{content_type});
$c->res->body($image->{data});
}
@@ -639,7 +655,7 @@ sub _nearby_json :Private {
my $list_html = $c->render_fragment(
'report/nearby.html',
- { reports => $nearby }
+ { reports => $nearby, inline_maps => $c->get_param("inline_maps") ? 1 : 0 }
);
my $json = { pins => \@pins };
@@ -665,6 +681,33 @@ sub check_has_permission_to : Private {
return \%permissions;
};
+
+sub stash_category_groups : Private {
+ my ( $self, $c, $contacts, $combine_multiple ) = @_;
+
+ my %category_groups = ();
+ for my $category (@$contacts) {
+ my $group = $category->{group} // $category->get_extra_metadata('group') // [''];
+ # this could be an array ref or a string
+ my @groups = ref $group eq 'ARRAY' ? @$group : ($group);
+ if (scalar @groups > 1 && $combine_multiple) {
+ @groups = sort @groups;
+ $category->{group} = \@groups;
+ push( @{$category_groups{_('Multiple Groups')}}, $category );
+ } else {
+ push( @{$category_groups{$_}}, $category ) for @groups;
+ }
+ }
+
+ my @category_groups = ();
+ for my $group ( grep { $_ ne _('Other') && $_ ne _('Multiple Groups') } sort keys %category_groups ) {
+ push @category_groups, { name => $group, categories => $category_groups{$group} };
+ }
+ push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')});
+ push @category_groups, { name => _('Multiple Groups'), categories => $category_groups{_('Multiple Groups')} } if ($category_groups{_('Multiple Groups')});
+ $c->stash->{category_groups} = \@category_groups;
+}
+
__PACKAGE__->meta->make_immutable;
1;
diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm
index 8944a9307..fc1a78cd5 100644
--- a/perllib/FixMyStreet/App/Controller/Report/New.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/New.pm
@@ -4,10 +4,10 @@ use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use utf8;
use Encode;
use List::MoreUtils qw(uniq);
use List::Util 'first';
-use POSIX 'strcoll';
use HTML::Entities;
use Path::Class;
use Utils;
@@ -102,18 +102,18 @@ sub report_new : Path : Args(0) {
$c->stash->{template} = "report/new/fill_in_details.html";
$c->forward('setup_categories_and_bodies');
$c->forward('setup_report_extra_fields');
- $c->forward('generate_map');
$c->forward('check_for_category');
+ $c->forward('setup_report_extras');
# deal with the user and report and check both are happy
- return unless $c->forward('check_form_submitted');
+ $c->detach('generate_map') unless $c->forward('check_form_submitted');
$c->forward('/auth/check_csrf_token');
$c->forward('process_report');
$c->forward('process_user');
$c->forward('/photo/process_photo');
- return unless $c->forward('check_for_errors');
+ $c->detach('generate_map') unless $c->forward('check_for_errors');
$c->forward('save_user_and_report');
$c->forward('redirect_or_confirm_creation');
}
@@ -142,6 +142,7 @@ sub report_new_ajax : Path('mobile') : Args(0) {
$c->forward('setup_categories_and_bodies');
$c->forward('setup_report_extra_fields');
+ $c->forward('check_for_category');
$c->forward('process_report');
$c->forward('process_user');
$c->forward('/photo/process_photo');
@@ -157,7 +158,7 @@ sub report_new_ajax : Path('mobile') : Args(0) {
my $report = $c->stash->{report};
if ( $report->confirmed ) {
- $c->forward( 'create_reporter_alert' );
+ $c->forward( 'create_related_things' );
$c->stash->{ json_response } = { success => 1, report => $report->id };
} else {
$c->forward( 'send_problem_confirm_email' );
@@ -201,6 +202,10 @@ sub report_form_ajax : Path('ajax') : Args(0) {
my $extra_titles_list = $c->cobrand->title_list($c->stash->{all_areas});
my @list_of_names = map { $_->name } values %{$c->stash->{bodies}};
+ my %display_names = map {
+ my $name = $_->cobrand_name;
+ ( $_->name ne $name ) ? ( $_->name => $name ) : ();
+ } values %{$c->stash->{bodies}};
my $contribute_as = {};
if ($c->user_exists) {
my @bodies = keys %{$c->stash->{bodies}};
@@ -227,6 +232,7 @@ sub report_form_ajax : Path('ajax') : Args(0) {
category => $category,
extra_name_info => $extra_name_info,
titles_list => $extra_titles_list,
+ %display_names ? (display_names => \%display_names) : (),
%$contribute_as ? (contribute_as => $contribute_as) : (),
$top_message ? (top_message => $top_message) : (),
unresponsive => $c->stash->{unresponsive}->{ALL} || '',
@@ -247,42 +253,54 @@ sub category_extras_ajax : Path('category_extras') : Args(0) {
$c->forward('setup_report_extra_fields');
$c->forward('check_for_category');
- my $category = $c->stash->{category} || "";
- $category = '' if $category eq _('-- Pick a category --');
-
- $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $category ]);
+ $c->stash->{json_response} = $c->forward('by_category_ajax_data', [ 'one', $c->stash->{category} ]);
$c->forward('send_json_response');
}
sub by_category_ajax_data : Private {
my ($self, $c, $type, $category) = @_;
- my $generate;
- if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or
- $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) {
- $generate = 1;
+ my @bodies;
+ my $bodies = [];
+ my $vars = {};
+ if ($category) {
+ $bodies = $c->forward('contacts_to_bodies', [ $category ]);
+ @bodies = @$bodies;
+ $vars->{list_of_names} = [ map { $_->cobrand_name } @bodies ];
+ } else {
+ @bodies = values %{$c->stash->{bodies_to_list}};
}
- my $bodies = $c->forward('contacts_to_bodies', [ $category ]);
- my $list_of_names = [ map { $_->name } ($category ? @$bodies : values %{$c->stash->{bodies_to_list}}) ];
- my $vars = {
- $category ? (list_of_names => $list_of_names) : (),
- };
-
+ my $non_public = $c->stash->{non_public_categories}->{$category};
+ my $anon_button = ($c->cobrand->allow_anonymous_reports($category) eq 'button');
my $body = {
- bodies => $list_of_names,
+ bodies => [ map { $_->name } @bodies ],
+ $non_public ? ( non_public => JSON->true ) : (),
+ $anon_button ? ( allow_anonymous => JSON->true ) : (),
};
- if ($generate) {
+ if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) {
+ my $disable_form = $c->forward('disable_form_message');
+ $body->{disable_form} = $disable_form if %$disable_form;
+
+ # Remove the full disable_form extras, as included in disable form output
+ @{$c->stash->{category_extras}->{$c->stash->{category}}} = grep {
+ !$_->{disable_form} || $_->{disable_form} ne 'true'
+ } @{$c->stash->{category_extras}->{$c->stash->{category}}};
+ }
+
+ if (($c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1) or
+ $c->stash->{unresponsive}->{$category} or $c->stash->{report_extra_fields}) {
$body->{category_extra} = $c->render_fragment('report/new/category_extras.html', $vars);
$body->{category_extra_json} = $c->forward('generate_category_extra_json');
-
}
my $unresponsive = $c->stash->{unresponsive}->{$category};
$unresponsive ||= $c->stash->{unresponsive}->{ALL} || '' if $type eq 'one';
# unresponsive must return empty string if okay, as that's what mobile app checks
+ # councils_text.html must be rendered if it differs from the default output,
+ # which currently means for unresponsive and non_public categories.
if ($type eq 'one' || ($type eq 'all' && $unresponsive)) {
$body->{unresponsive} = $unresponsive;
# Check for no bodies here, because if there are any (say one
@@ -292,10 +310,41 @@ sub by_category_ajax_data : Private {
$body->{councils_text_private} = $c->render_fragment( 'report/new/councils_text_private.html');
}
}
+ if ($non_public) {
+ $body->{councils_text} = $c->render_fragment( 'report/new/councils_text.html', $vars);
+ }
return $body;
}
+sub disable_form_message : Private {
+ my ( $self, $c ) = @_;
+
+ my %out;
+
+ # do not set disable form message if they are a staff user
+ return \%out if $c->cobrand->call_hook('staff_ignore_form_disable_form');
+
+ foreach (@{$c->stash->{category_extras}->{$c->stash->{category}}}) {
+ if ($_->{disable_form} && $_->{disable_form} eq 'true') {
+ $out{all} .= ' ' if $out{all};
+ $out{all} .= $_->{description};
+ } elsif (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) {
+ my %category;
+ foreach my $opt (@{$_->{values}}) {
+ if ($opt->{disable}) {
+ $category{message} = $opt->{disable_message} || $_->{datatype_description};
+ $category{code} = $_->{code};
+ push @{$category{answers}}, $opt->{key};
+ }
+ }
+ push @{$out{questions}}, \%category if %category;
+ }
+ }
+
+ return \%out;
+}
+
=head2 report_import
Action to accept report creations from iPhones and other mobile apps. URL is
@@ -309,8 +358,7 @@ sub report_import : Path('/import') {
# If this is not a POST then just print out instructions for using page
return unless $c->req->method eq 'POST';
- # anything else we return is plain text
- $c->res->content_type('text/plain; charset=utf-8');
+ my $format = $c->get_param('web') ? 'web' : 'text';
my %input =
map { $_ => $c->get_param($_) || '' } (
@@ -363,8 +411,14 @@ sub report_import : Path('/import') {
# if we have errors then we should bail out
if (@errors) {
- my $body = join '', map { "ERROR:$_\n" } @errors;
- $c->res->body($body);
+ if ($format eq 'web') {
+ $c->stash->{input} = \%input;
+ $c->stash->{errors} = \@errors;
+ } else {
+ my $body = join '', map { "ERROR:$_\n" } @errors;
+ $c->res->content_type('text/plain; charset=utf-8');
+ $c->res->body($body);
+ }
return;
}
@@ -420,13 +474,13 @@ sub report_import : Path('/import') {
$c->send_email( 'partial.txt', { to => $report->user->email, } );
- if ( $c->get_param('web') ) {
- $c->res->content_type('text/html; charset=utf-8');
+ if ($format eq 'web') {
$c->stash->{template} = 'email_sent.html';
$c->stash->{email_type} = 'problem';
- return 1;
+ } else {
+ $c->res->content_type('text/plain; charset=utf-8');
+ $c->res->body('SUCCESS');
}
- $c->res->body('SUCCESS');
return 1;
}
@@ -476,9 +530,11 @@ sub initialize_report : Private {
->first;
if ($report) {
- # log the problem creation user in to the site
- $c->authenticate( { email => $report->user->email, email_verified => 1 },
- 'no_password' );
+ # log the problem creation user in to the site, if not already logged in
+ if (!$c->user_exists || $c->user->email ne $report->user->email) {
+ $c->authenticate( { email => $report->user->email, email_verified => 1 },
+ 'no_password' );
+ }
# save the token to delete at the end
$c->stash->{partial_token} = $token if $report;
@@ -637,12 +693,12 @@ sub setup_categories_and_bodies : Private {
my @bodies = $c->model('DB::Body')->active->for_areas(keys %$all_areas)->all;
my %bodies = map { $_->id => $_ } @bodies;
- my $contacts #
- = $c #
- ->model('DB::Contact') #
- ->active
- ->search( { 'me.body_id' => [ keys %bodies ] }, { prefetch => 'body' } );
- my @contacts = $c->cobrand->categories_restriction($contacts)->all;
+ $c->cobrand->call_hook(munge_report_new_bodies => \%bodies);
+
+ my $contacts = $c->model('DB::Contact')->for_new_reports($c, \%bodies);
+ my @contacts = $c->cobrand->categories_restriction($contacts)->all_sorted;
+
+ $c->cobrand->call_hook(munge_report_new_contacts => \@contacts);
# variables to populate
my %bodies_to_list = (); # Bodies with categories assigned
@@ -650,6 +706,8 @@ sub setup_categories_and_bodies : Private {
my %category_extras = (); # extra fields to fill in for open311
my %category_extras_hidden =
(); # whether all of a category's fields are hidden
+ my %category_extras_notices =
+ (); # whether all of a category's fields are simple notices and not inputs
my %non_public_categories =
(); # categories for which the reports are not public
$c->stash->{unresponsive} = {};
@@ -668,15 +726,6 @@ sub setup_categories_and_bodies : Private {
$c->stash->{unresponsive}{$k} = { map { $_ => 1 } keys %bodies };
}
- # keysort does not appear to obey locale so use strcoll (see i18n.t)
- @contacts = sort { strcoll( $a->category, $b->category ) } @contacts;
-
- # Get defect types for inspectors
- if ($c->cobrand->can('council_area_id')) {
- my $category_defect_types = FixMyStreet::App->model('DB::DefectType')->by_categories($c->cobrand->council_area_id, @contacts);
- $c->stash->{category_defect_types} = $category_defect_types;
- }
-
my %seen;
foreach my $contact (@contacts) {
@@ -691,6 +740,16 @@ sub setup_categories_and_bodies : Private {
} else {
$category_extras_hidden{$contact->category} = $all_hidden;
}
+
+ my $all_notices = (grep {
+ ( $_->{variable} || '' ) ne 'false'
+ && !$c->cobrand->category_extra_hidden($_)
+ } @$metas) ? 0 : 1;
+ if (exists($category_extras_notices{$contact->category})) {
+ $category_extras_notices{$contact->category} &&= $all_notices;
+ } else {
+ $category_extras_notices{$contact->category} = $all_notices;
+ }
}
$non_public_categories{ $contact->category } = 1 if $contact->non_public;
@@ -712,15 +771,17 @@ sub setup_categories_and_bodies : Private {
push @category_options, $seen{_('Other')} if $seen{_('Other')};
}
- $c->cobrand->call_hook(munge_category_list => \@category_options, \@contacts, \%category_extras);
+ $c->cobrand->call_hook(munge_report_new_category_list => \@category_options, \@contacts, \%category_extras);
# put results onto stash for display
$c->stash->{bodies} = \%bodies;
$c->stash->{contacts} = \@contacts;
$c->stash->{bodies_to_list} = \%bodies_to_list;
+ $c->stash->{bodies_ids} = [ map { $_ } keys %bodies ];
$c->stash->{category_options} = \@category_options;
$c->stash->{category_extras} = \%category_extras;
$c->stash->{category_extras_hidden} = \%category_extras_hidden;
+ $c->stash->{category_extras_notices} = \%category_extras_notices;
$c->stash->{non_public_categories} = \%non_public_categories;
$c->stash->{extra_name_info} = $first_area->{id} == COUNCIL_ID_BROMLEY ? 1 : 0;
@@ -736,20 +797,7 @@ sub setup_categories_and_bodies : Private {
$c->stash->{missing_details_bodies} = \@missing_details_bodies;
$c->stash->{missing_details_body_names} = \@missing_details_body_names;
- if ( $c->cobrand->call_hook('enable_category_groups') ) {
- my %category_groups = ();
- for my $category (@category_options) {
- my $group = $category->{group} // $category->get_extra_metadata('group') // '';
- push @{$category_groups{$group}}, $category;
- }
-
- my @category_groups = ();
- for my $group ( grep { $_ ne _('Other') } sort keys %category_groups ) {
- push @category_groups, { name => $group, categories => $category_groups{$group} };
- }
- push @category_groups, { name => _('Other'), categories => $category_groups{_('Other')} } if ($category_groups{_('Other')});
- $c->stash->{category_groups} = \@category_groups;
- }
+ $c->forward('/report/stash_category_groups', [ \@category_options ]) if $c->cobrand->enable_category_groups;
}
sub setup_report_extra_fields : Private {
@@ -794,10 +842,17 @@ sub process_user : Private {
# Report form includes two username fields: #form_username_register and #form_username_sign_in
$params{username} = (first { $_ } $c->get_param_list('username')) || '';
- if ( $c->cobrand->allow_anonymous_reports ) {
+ my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously');
+ my $anon_fallback = $c->cobrand->allow_anonymous_reports eq '1' && !$c->user_exists && !$params{username};
+ if ($anon_button || $anon_fallback) {
my $anon_details = $c->cobrand->anonymous_account;
- $params{username} ||= $anon_details->{email};
- $params{name} ||= $anon_details->{name};
+ my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} });
+ $user->name($anon_details->{name});
+ $report->user($user);
+ $report->name($user->name);
+ $c->stash->{no_reporter_alert} = 1;
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ return 1;
}
# The user is already signed in. Extra bare block for 'last'.
@@ -805,9 +860,11 @@ sub process_user : Private {
my $user = $c->user->obj;
if ($c->stash->{contributing_as_another_user}) {
- # Act as if not logged in (and it will be auto-confirmed later on)
- $report->user(undef);
- last;
+ if ($params{username} || $params{phone}) {
+ # Act as if not logged in (and it will be auto-confirmed later on)
+ $report->user(undef);
+ last;
+ }
}
$report->user( $user );
@@ -821,6 +878,8 @@ sub process_user : Private {
$report->name($name);
$user->name($name) unless $user->name;
$c->stash->{no_reporter_alert} = 1;
+ } elsif ($c->stash->{contributing_as_another_user}) {
+ $c->stash->{no_reporter_alert} = 1;
}
return 1;
@@ -854,7 +913,7 @@ sub process_user : Private {
oauth_report => { $report->get_inflated_columns }
};
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
- $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
+ $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.');
return 1;
}
my $user = $c->user->obj;
@@ -907,12 +966,12 @@ sub process_report : Private {
'title', 'detail', 'pc', #
'detail_size',
'may_show_name', #
- 'category', #
'subcategory', #
'partial', #
'service', #
'non_public',
);
+ $params{category} = $c->stash->{category};
# load the report
my $report = $c->stash->{report};
@@ -929,6 +988,13 @@ sub process_report : Private {
$c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies});
$c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies});
}
+ # This is also done in process_user, but is needed here for anonymous() just below
+ my $anon_button = $c->cobrand->allow_anonymous_reports($params{category}) eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ $c->stash->{contributing_as_body} = undef;
+ $c->stash->{contributing_as_another_user} = undef;
+ }
# set some simple bool values (note they get inverted)
if ($c->stash->{contributing_as_body}) {
@@ -971,12 +1037,20 @@ sub process_report : Private {
}
# check that we've not indicated we only want to sent to a single body
- # and if we find a matching one then only send to that. e.g. if we clicked
- # on a TfL road on the map.
+ # and if we find a matching one then only send to that.
my $body_string = do {
if (my $single_body_only = $c->get_param('single_body_only')) {
my $body = $c->model('DB::Body')->search({ name => $single_body_only })->first;
- $body ? $body->id : '-1';
+ if ($body) {
+ # Drop the contacts down to those in this body
+ # (potentially none for e.g. Highways England)
+ # so that set_report_extras doesn't error when
+ # there are 'missing' extra fields
+ @contacts = grep { $_->body->id == $body->id } @contacts;
+ $body->id;
+ } else {
+ '-1';
+ }
} else {
my $contact_options = {};
$contact_options->{do_not_send} = [ $c->get_param_list('do_not_send', 1) ];
@@ -1065,18 +1139,26 @@ sub contacts_to_bodies : Private {
[ map { $_->body } @contacts ];
}
+sub setup_report_extras : Private {
+ my ($self, $c) = @_;
+
+ # report_meta is used by the templates to fill in the extra field values
+ my $extra = $c->stash->{report}->get_extra_fields;
+ $c->stash->{report_meta} = { map { 'x' . $_->{name} => $_ } @$extra };
+}
+
sub set_report_extras : Private {
my ($self, $c, $contacts, $param_prefix) = @_;
$param_prefix ||= "";
- my @metalist = map { [ $_->get_metadata_for_input, $param_prefix ] } @$contacts;
+ my @metalist = map { [ $_->get_metadata_for_storage, $param_prefix ] } @$contacts;
push @metalist, map { [ $_->get_extra_fields, "extra[" . $_->id . "]" ] } @{$c->stash->{report_extra_fields}};
my @extra;
foreach my $item (@metalist) {
my ($metas, $param_prefix) = @$item;
foreach my $field ( @$metas ) {
- if ( lc( $field->{required} ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) {
+ if ( lc( $field->{required} || '' ) eq 'true' && !$c->cobrand->category_extra_hidden($field)) {
unless ( $c->get_param($param_prefix . $field->{code}) ) {
$c->stash->{field_errors}->{ 'x' . $field->{code} } = _('This information is required');
}
@@ -1112,12 +1194,13 @@ sub check_for_errors : Private {
$c->stash->{field_errors} ||= {};
my %field_errors = $c->cobrand->report_check_for_errors( $c );
+ my $report = $c->stash->{report};
+
# Zurich, we don't care about title or name
# There is no title, and name is optional
if ( $c->cobrand->moniker eq 'zurich' ) {
delete $field_errors{title};
delete $field_errors{name};
- my $report = $c->stash->{report};
$report->title( Utils::cleanup_text( substr($report->detail, 0, 25) ) );
# We only want to validate the phone number web requests (where the
@@ -1137,8 +1220,13 @@ sub check_for_errors : Private {
delete $field_errors{name};
}
+ # If we're making an anonymous report, we do not care about the name field
+ if ( $c->stash->{contributing_as_anonymous_user} ) {
+ delete $field_errors{name};
+ }
+
# if using social login then we don't care about other errors
- $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in');
+ $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0;
if ( $c->stash->{is_social_user} ) {
delete $field_errors{name};
delete $field_errors{username};
@@ -1162,9 +1250,8 @@ sub check_for_errors : Private {
if ( $c->cobrand->allow_anonymous_reports ) {
my $anon_details = $c->cobrand->anonymous_account;
- my $report = $c->stash->{report};
$report->user->email(undef) if $report->user->email eq $anon_details->{email};
- $report->name(undef) if $report->name eq $anon_details->{name};
+ $report->name(undef) if $report->name && $report->name eq $anon_details->{name};
}
return;
@@ -1180,10 +1267,8 @@ sub tokenize_user : Private {
password => $report->user->password,
title => $report->user->title,
};
- $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+ $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ])
+ if $c->get_param('oauth_need_email');
}
sub send_problem_confirm_email : Private {
@@ -1278,7 +1363,7 @@ sub process_confirmation : Private {
);
# Subscribe problem reporter to email updates
- $c->forward( '/report/new/create_reporter_alert' );
+ $c->forward( '/report/new/create_related_things' );
# log the problem creation user in to the site
if ( $data->{name} || $data->{password} ) {
@@ -1291,7 +1376,21 @@ sub process_confirmation : Private {
for (qw(name title facebook_id twitter_id)) {
$problem->user->$_( $data->{$_} ) if $data->{$_};
}
+ $problem->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id};
+ $problem->user->extra({
+ %{ $problem->user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
+
$problem->user->update;
+
+ # Make sure extra oauth state is restored, if applicable
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
}
if ($problem->user->email_verified) {
$c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' );
@@ -1351,11 +1450,7 @@ sub save_user_and_report : Private {
$c->stash->{detach_to} = '/report/new/oauth_callback';
$c->stash->{detach_args} = [$token->token];
- if ( $c->get_param('facebook_sign_in') ) {
- $c->detach('/auth/social/facebook_sign_in');
- } elsif ( $c->get_param('twitter_sign_in') ) {
- $c->detach('/auth/social/twitter_sign_in');
- }
+ $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in');
}
# Save or update the user if appropriate
@@ -1373,6 +1468,13 @@ sub save_user_and_report : Private {
$report->confirm();
} elsif ($c->stash->{contributing_as_anonymous_user}) {
$report->set_extra_metadata( contributed_as => 'anonymous_user' );
+ if ( $c->user_exists && $c->user->from_body ) {
+ # If a staff user has clicked the 'report anonymously' button then
+ # there would be no record of who that staff member was as we've
+ # used the cobrand's anonymous_account for the report. In this case
+ # record the staff user ID in the report metadata.
+ $report->set_extra_metadata( contributed_by => $c->user->id );
+ }
$report->confirm();
} elsif ( !$report->user->in_storage ) {
# User does not exist.
@@ -1450,9 +1552,61 @@ sub generate_map : Private {
sub check_for_category : Private {
my ( $self, $c ) = @_;
- $c->stash->{category} = $c->get_param('category');
+ my $category = $c->get_param('category') || $c->stash->{report}->category || '';
+ $category = '' if $category eq _('Loading...') || $category eq _('-- Pick a category --');
+ # Just check to see if the filter had an option
+ $category ||= $c->get_param('filter_category') || '';
+ $c->stash->{category} = $category;
- return 1;
+ # Bit of a copy of set_report_extras, because we need the results here, but
+ # don't want to run all of that fn until later as it e.g. alters field
+ # errors at that point. Also, the report might already have some answers in
+ # too if e.g. gone via social login... TODO Improve this?
+ my $extra = $c->stash->{report}->get_extra_fields;
+ my %current = map { $_->{name} => $_ } @$extra;
+
+ my @contacts = grep { $_->category eq $category } @{$c->stash->{contacts}};
+ my @metalist = map { @{$_->get_metadata_for_storage} } @contacts;
+ my @extra;
+ foreach my $field (@metalist) {
+ push @extra, {
+ name => $field->{code},
+ description => $field->{description},
+ value => $c->get_param($field->{code}) || $current{$field->{code}}{value} || '',
+ };
+ }
+ $c->stash->{report}->set_extra_fields( @extra );
+
+ # Work out if the selected category (or category extra question answer) should lead
+ # to a message being shown not to use the form
+ if ( $c->stash->{category_extras}->{$category} && @{ $c->stash->{category_extras}->{$category} } >= 1 ) {
+ my $disable_form_messages = $c->forward('disable_form_message');
+ if ($disable_form_messages->{all}) {
+ $c->stash->{disable_form_message} = $disable_form_messages->{all};
+ } elsif (my $questions = $disable_form_messages->{questions}) {
+ foreach my $question (@$questions) {
+ my $answer = $c->get_param($question->{code});
+ my $message = $question->{message};
+ if ($answer) {
+ foreach (@{$question->{answers}}) {
+ if ($answer eq $_) {
+ $c->stash->{disable_form_message} = $message;
+ }
+ }
+ }
+ }
+ if (!$c->stash->{disable_form_message}) {
+ $c->stash->{have_disable_qn_to_answer} = 1;
+ }
+ }
+ }
+
+ if ($c->get_param('submit_category_part_only') || $c->stash->{disable_form_message}) {
+ # If we've clicked the first-part category button (no-JS only probably),
+ # or the category submitted will be showing a disabled form message,
+ # we only want to reshow the form
+ $c->stash->{force_form_not_submitted} = 1;
+ }
}
=head2 redirect_or_confirm_creation
@@ -1469,8 +1623,9 @@ sub redirect_or_confirm_creation : Private {
# If confirmed send the user straight there.
if ( $report->confirmed ) {
# Subscribe problem reporter to email updates
- $c->forward( 'create_reporter_alert' );
+ $c->forward( 'create_related_things' );
if ($c->stash->{contributing_as_another_user} && $report->user->email
+ && $report->user->id != $c->user->id
&& !$c->cobrand->report_sent_confirmation_email) {
$c->send_email( 'other-reported.txt', {
to => [ [ $report->user->email, $report->name ] ],
@@ -1491,7 +1646,7 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $report->user->has_2fa;
# otherwise email or text a confirm token to them.
@@ -1510,12 +1665,51 @@ sub redirect_or_confirm_creation : Private {
$c->log->info($report->user->id . ' created ' . $report->id . ", $thing sent, " . ($c->stash->{token_data}->{password} ? 'password set' : 'password not set'));
}
-sub create_reporter_alert : Private {
+sub create_related_things : Private {
my ( $self, $c ) = @_;
+ my $problem = $c->stash->{report};
+
+ # If there is a special template, create a comment using that
+ foreach my $body (values %{$problem->bodies}) {
+ my $user = $body->comment_user or next;
+
+ my %open311_conf = (
+ endpoint => $body->endpoint || '',
+ api_key => $body->api_key || '',
+ jurisdiction => $body->jurisdiction || '',
+ extended_statuses => $body->send_extended_statuses,
+ );
+
+ my $cobrand = $body->get_cobrand_handler;
+ $cobrand->call_hook(open311_config_updates => \%open311_conf)
+ if $cobrand;
+
+ my $open311 = Open311->new(%open311_conf);
+ my $updates = Open311::GetServiceRequestUpdates->new(
+ system_user => $user,
+ current_open311 => $open311,
+ current_body => $body,
+ blank_updates_permitted => 1,
+ );
+
+ my $description = $updates->comment_text_for_request({}, $problem, 'confirmed', 'dummy', '', '');
+ next unless $description;
+
+ my $request = {
+ service_request_id => $problem->id,
+ update_id => 'auto-internal',
+ comment_time => DateTime->now,
+ status => 'open',
+ description => $description,
+ };
+ $updates->process_update($request, $problem);
+ }
+
+ # And now the reporter alert
return if $c->stash->{no_reporter_alert};
+ return if $c->cobrand->call_hook('suppress_reporter_alerts');
- my $problem = $c->stash->{report};
my $alert = $c->model('DB::Alert')->find_or_create( {
user => $problem->user,
alert_type => 'new_updates',
@@ -1576,12 +1770,24 @@ sub generate_category_extra_json : Private {
my $false = JSON->false;
my @fields = map {
- {
- %$_,
- required => $_->{required} eq "true" ? $true : $false,
- variable => $_->{variable} eq "true" ? $true : $false,
- order => int($_->{order}),
+ my %data = %$_;
+
+ # Mobile app still looks in datatype_description
+ if (($_->{variable} || '') eq 'true' && @{$_->{values} || []}) {
+ foreach my $opt (@{$_->{values}}) {
+ if ($opt->{disable}) {
+ my $message = $opt->{disable_message} || $_->{datatype_description};
+ $data{datatype_description} = $message;
+ }
+ }
}
+
+ # Remove unneeded
+ delete $data{$_} for qw(datatype protected variable order disable_form);
+ delete $data{datatype_description} unless $data{datatype_description};
+
+ $data{required} = ($_->{required} || '') eq "true" ? $true : $false;
+ \%data;
} @{ $c->stash->{category_extras}->{$c->stash->{category}} };
return \@fields;
diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm
index cbedf7a01..41c42b8a1 100644
--- a/perllib/FixMyStreet/App/Controller/Report/Update.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm
@@ -4,6 +4,7 @@ use Moose;
use namespace::autoclean;
BEGIN { extends 'Catalyst::Controller'; }
+use utf8;
use Path::Class;
use List::Util 'first';
use Utils;
@@ -105,6 +106,17 @@ sub process_user : Private {
# Update form includes two username fields: #form_username_register and #form_username_sign_in
$params{username} = (first { $_ } $c->get_param_list('username')) || '';
+ my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ my $anon_details = $c->cobrand->anonymous_account;
+ my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} });
+ $user->name($anon_details->{name});
+ $update->user($user);
+ $update->name($user->name);
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ return 1;
+ }
+
# Extra block to use 'last'
if ( $c->user_exists ) { {
my $user = $c->user->obj;
@@ -115,13 +127,16 @@ sub process_user : Private {
}
$user->name( Utils::trim_text( $params{name} ) ) if $params{name};
+ $update->name($user->name);
my $title = Utils::trim_text( $params{fms_extra_title} );
$user->title( $title ) if $title;
$update->user( $user );
# Just in case, make sure the user will have a name
if ($c->stash->{contributing_as_body} or $c->stash->{contributing_as_anonymous_user}) {
- $user->name($user->from_body->name) unless $user->name;
+ my $name = $user->moderating_user_name;
+ $update->name($name);
+ $user->name($name) unless $user->name;
}
return 1;
@@ -143,7 +158,7 @@ sub process_user : Private {
oauth_update => { $update->get_inflated_columns }
};
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
- $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
+ $c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the ‘No’ section of the form.');
return 1;
}
my $user = $c->user->obj;
@@ -155,6 +170,7 @@ sub process_user : Private {
$update->user->name( Utils::trim_text( $params{name} ) )
if $params{name};
+ $update->name($update->user->name);
$update->user->title( Utils::trim_text( $params{fms_extra_title} ) )
if $params{fms_extra_title};
@@ -244,8 +260,7 @@ This makes sure we only proceed to processing if we've had the form submitted
sub check_form_submitted : Private {
my ( $self, $c ) = @_;
- return if $c->stash->{problem}->get_extra_metadata('closed_updates');
- return if $c->cobrand->call_hook(updates_disallowed => $c->stash->{problem});
+ return if $c->cobrand->updates_disallowed($c->stash->{problem});
return $c->get_param('submit_update') || '';
}
@@ -277,14 +292,21 @@ sub process_update : Private {
$c->stash->{contributing_as_body} = $c->user_exists && $c->user->contributing_as('body', $c, $update->problem->bodies_str_ids);
$c->stash->{contributing_as_anonymous_user} = $c->user_exists && $c->user->contributing_as('anonymous_user', $c, $update->problem->bodies_str_ids);
+
+ # This is also done in process_user, but is needed here for anonymous() just below
+ my $anon_button = $c->cobrand->allow_anonymous_reports($update->problem->category) eq 'button' && $c->get_param('report_anonymously');
+ if ($anon_button) {
+ $c->stash->{contributing_as_anonymous_user} = 1;
+ $c->stash->{contributing_as_body} = undef;
+ $c->stash->{contributing_as_another_user} = undef;
+ }
+
+
if ($c->stash->{contributing_as_body}) {
- $update->name($c->user->from_body->name);
$update->anonymous(0);
} elsif ($c->stash->{contributing_as_anonymous_user}) {
- $update->name($c->user->from_body->name);
$update->anonymous(1);
} else {
- $update->name($name);
$update->anonymous($c->get_param('may_show_name') ? 0 : 1);
}
@@ -366,7 +388,7 @@ sub check_for_errors : Private {
);
# if using social login then we don't care about name and email errors
- $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in');
+ $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0;
if ( $c->stash->{is_social_user} ) {
delete $field_errors{name};
delete $field_errors{username};
@@ -394,6 +416,13 @@ sub check_for_errors : Private {
#push @{ $c->stash->{errors} },
# _('There were problems with your update. Please see below.');
+ if ( $c->cobrand->allow_anonymous_reports ) {
+ my $anon_details = $c->cobrand->anonymous_account;
+ my $update = $c->stash->{update};
+ $update->user->email(undef) if $update->user->email eq $anon_details->{email};
+ $update->name(undef) if $update->name && $update->name eq $anon_details->{name};
+ }
+
return;
}
@@ -404,10 +433,8 @@ sub tokenize_user : Private {
name => $update->user->name,
password => $update->user->password,
};
- $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id};
- $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id}
- if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id};
+ $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ])
+ if $c->get_param('oauth_need_email');
}
=head2 save_update
@@ -440,11 +467,7 @@ sub save_update : Private {
$c->stash->{detach_to} = '/report/update/oauth_callback';
$c->stash->{detach_args} = [$token->token];
- if ( $c->get_param('facebook_sign_in') ) {
- $c->detach('/auth/social/facebook_sign_in');
- } elsif ( $c->get_param('twitter_sign_in') ) {
- $c->detach('/auth/social/twitter_sign_in');
- }
+ $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in');
}
if ( $c->cobrand->never_confirm_updates ) {
@@ -508,7 +531,7 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
- # Superusers using 2FA can not log in by code
+ # People using 2FA can not log in by code
$c->detach( '/page_error_403_access_denied', [] ) if $update->user->has_2fa;
my $data = $c->stash->{token_data};
@@ -585,8 +608,20 @@ sub process_confirmation : Private {
for (qw(name facebook_id twitter_id)) {
$comment->user->$_( $data->{$_} ) if $data->{$_};
}
+ $comment->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id};
+ $comment->user->extra({
+ %{ $comment->user->get_extra() },
+ %{ $data->{extra} }
+ }) if $data->{extra};
$comment->user->password( $data->{password}, 1 ) if $data->{password};
$comment->user->update;
+ # Make sure extra oauth state is restored, if applicable
+ foreach (qw/logout_redirect_uri change_password_uri/) {
+ if ($data->{$_}) {
+ $c->session->{oauth} ||= ();
+ $c->session->{oauth}{$_} = $data->{$_};
+ }
+ }
}
if ($comment->user->email_verified) {
@@ -636,6 +671,8 @@ sub signup_for_alerts : Private {
$alert->disable();
}
+ $c->cobrand->call_hook(update_email_shortlisted_user => $update);
+
return 1;
}
diff --git a/perllib/FixMyStreet/App/Controller/Reports.pm b/perllib/FixMyStreet/App/Controller/Reports.pm
index 49bdce379..97976ebe3 100644
--- a/perllib/FixMyStreet/App/Controller/Reports.pm
+++ b/perllib/FixMyStreet/App/Controller/Reports.pm
@@ -151,6 +151,7 @@ sub ward : Path : Args(2) {
if @wards;
$c->forward( 'check_canonical_url', [ $body ] );
$c->forward( 'stash_report_filter_status' );
+ $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]);
$c->forward( 'load_and_group_problems' );
if ($c->get_param('ajax')) {
@@ -164,20 +165,44 @@ sub ward : Path : Args(2) {
$c->stash->{stats} = $c->cobrand->get_report_stats();
+ $c->forward('setup_categories_and_map');
+
+ # List of wards
+ if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) {
+ my $children = $c->stash->{body}->first_area_children;
+ unless ($children->{error}) {
+ foreach (values %$children) {
+ $_->{url} = $c->uri_for( $c->stash->{body_url}
+ . '/' . $c->cobrand->short_name( $_ )
+ );
+ }
+ $c->stash->{children} = $children;
+ }
+ }
+}
+
+sub setup_categories_and_map :Private {
+ my ($self, $c) = @_;
+
my @categories = $c->stash->{body}->contacts->not_deleted->search( undef, {
- columns => [ 'id', 'category', 'extra' ],
+ columns => [ 'id', 'category', 'extra', 'body_id', 'send_method' ],
distinct => 1,
- order_by => [ 'category' ],
- } )->all;
+ } )->all_sorted;
+
+ $c->cobrand->call_hook('munge_reports_category_list', \@categories);
+
$c->stash->{filter_categories} = \@categories;
$c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) };
+ $c->forward('/report/stash_category_groups', [ \@categories ]) if $c->cobrand->enable_category_groups;
my $pins = $c->stash->{pins} || [];
+ my $areas = [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ];
+ $c->cobrand->call_hook(munge_reports_area_list => $areas);
my %map_params = (
latitude => @$pins ? $pins->[0]{latitude} : 0,
longitude => @$pins ? $pins->[0]{longitude} : 0,
- area => [ $c->stash->{wards} ? map { $_->{id} } @{$c->stash->{wards}} : keys %{$c->stash->{body}->areas} ],
+ area => $areas,
any_zoom => 1,
);
FixMyStreet::Map::display_map(
@@ -185,19 +210,6 @@ sub ward : Path : Args(2) {
);
$c->cobrand->tweak_all_reports_map( $c );
-
- # List of wards
- if ( !$c->stash->{wards} && $c->stash->{body}->id && $c->stash->{body}->body_areas->first ) {
- my $children = $c->stash->{body}->first_area_children;
- unless ($children->{error}) {
- foreach (values %$children) {
- $_->{url} = $c->uri_for( $c->stash->{body_url}
- . '/' . $c->cobrand->short_name( $_ )
- );
- }
- $c->stash->{children} = $children;
- }
- }
}
sub rss_area : Path('/rss/area') : Args(1) {
@@ -287,12 +299,12 @@ sub rss_ward : Path('/rss/reports') : Args(2) {
if ($c->stash->{ward}) {
# Problems sent to a council, restricted to a ward
$c->stash->{type} = 'ward_problems';
- $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name, WARD => $c->stash->{ward}{name} };
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name, WARD => $c->stash->{ward}{name} };
$c->stash->{db_params} = [ $c->stash->{body}->id, $c->stash->{ward}->{id} ];
} else {
# Problems sent to a council
$c->stash->{type} = 'council_problems';
- $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->name };
+ $c->stash->{title_params} = { COUNCIL => $c->stash->{body}->cobrand_name };
$c->stash->{db_params} = [ $c->stash->{body}->id ];
}
@@ -391,9 +403,7 @@ sub ward_check : Private {
$parent_id = $c->stash->{area}->{id};
}
- my $qw = FixMyStreet::MapIt::call('area/children', [ $parent_id ],
- type => $c->cobrand->area_types_children,
- );
+ my $qw = $c->cobrand->fetch_area_children($parent_id);
my %names = map { $c->cobrand->short_name({ name => $_ }) => 1 } @wards;
my @areas;
foreach my $area (sort { $a->{name} cmp $b->{name} } values %$qw) {
@@ -548,9 +558,51 @@ sub load_dashboard_data : Private {
sub load_and_group_problems : Private {
my ( $self, $c ) = @_;
- $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]);
+ my $parameters = $c->forward('load_problems_parameters');
+ my $body = $c->stash->{body}; # Might be undef
my $page = $c->get_param('p') || 1;
+
+ my $problems = $c->cobrand->problems;
+ my $where = $parameters->{where};
+ my $filter = $parameters->{filter};
+
+ if ($where->{areas} || $body) {
+ $problems = $problems->to_body($body);
+ }
+
+ $problems = $problems->search(
+ $where,
+ $filter
+ )->include_comment_counts->page( $page );
+
+ $c->stash->{pager} = $problems->pager;
+
+ my ( %problems, @pins );
+ while ( my $problem = $problems->next ) {
+ if ( !$body ) {
+ add_row( $c, $problem, 0, \%problems, \@pins );
+ next;
+ }
+ # Add to bodies it was sent to
+ my $bodies = $problem->bodies_str_ids;
+ foreach ( @$bodies ) {
+ next if $_ != $body->id;
+ add_row( $c, $problem, $_, \%problems, \@pins );
+ }
+ }
+
+ $c->stash(
+ problems => \%problems,
+ pins => \@pins,
+ );
+
+ return 1;
+}
+
+sub load_problems_parameters : Private {
+ my ($self, $c) = @_;
+
my $category = [ $c->get_param_list('filter_category', 1) ];
my $states = $c->stash->{filter_problem_states};
@@ -563,7 +615,7 @@ sub load_and_group_problems : Private {
my $body = $c->stash->{body}; # Might be undef
my $filter = {
- order_by => $c->stash->{sort_order},
+ order_by => [ $c->stash->{sort_order}, { -desc => 'me.id' } ],
rows => $c->cobrand->reports_per_page,
};
if ($c->user_exists && $body) {
@@ -597,15 +649,10 @@ sub load_and_group_problems : Private {
$where->{category} = $category;
}
- my $problems = $c->cobrand->problems;
-
if ($c->stash->{wards}) {
$where->{areas} = [
map { { 'like', '%,' . $_->{id} . ',%' } } @{$c->stash->{wards}}
];
- $problems = $problems->to_body($body);
- } elsif ($body) {
- $problems = $problems->to_body($body);
}
if (my $bbox = $c->get_param('bbox')) {
@@ -614,52 +661,21 @@ sub load_and_group_problems : Private {
$where->{longitude} = { '>=', $min_lon, '<', $max_lon };
}
- my $cobrand_problems = $c->cobrand->call_hook('munge_load_and_group_problems', $where, $filter);
-
- # JS will request the same (or more) data client side
- return if $c->get_param('js');
+ $c->cobrand->call_hook('munge_load_and_group_problems', $where, $filter);
- if ($cobrand_problems) {
- $problems = $cobrand_problems;
- } else {
- $problems = $problems->search(
- $where,
- $filter
- )->include_comment_counts->page( $page );
-
- $c->stash->{pager} = $problems->pager;
- }
-
- my ( %problems, @pins );
- while ( my $problem = $problems->next ) {
- if ( !$body ) {
- add_row( $c, $problem, 0, \%problems, \@pins );
- next;
- }
- # Add to bodies it was sent to
- my $bodies = $problem->bodies_str_ids;
- foreach ( @$bodies ) {
- next if $_ != $body->id;
- add_row( $c, $problem, $_, \%problems, \@pins );
- }
- }
-
- $c->stash(
- problems => \%problems,
- pins => \@pins,
- );
-
- return 1;
+ return {
+ where => $where,
+ filter => $filter,
+ };
}
-
sub check_non_public_reports_permission : Private {
my ($self, $c, $where) = @_;
if ( $c->user_exists ) {
my $user_has_permission;
- if ( $c->user->is_super_user ) {
+ if ( $c->user->is_superuser ) {
$user_has_permission = 1;
} else {
my $body = $c->stash->{body};
@@ -702,8 +718,9 @@ sub stash_report_filter_status : Private {
my @status = $c->get_param_list('status', 1);
@status = ($c->stash->{page} eq 'my' ? 'all' : $c->cobrand->on_map_default_status) unless @status;
- my %status = map { $_ => 1 } @status;
+ $c->cobrand->call_hook(hook_report_filter_status => \@status);
+ my %status = map { $_ => 1 } @status;
my %filter_problem_states;
my %filter_status;
@@ -810,7 +827,12 @@ sub ajax : Private {
my @pins = map {
my $p = $_;
# lat, lon, 'colour', ID, title, type/size, draggable
- [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ]
+ my $parts = [ $p->{latitude}, $p->{longitude}, $p->{colour}, $p->{id}, $p->{title}, '', JSON->false ];
+ # Some reports may only be visible on a specific cobrand on this FMS site.
+ # If that's the case, include the base URL for the pin's cobrand here so
+ # the app can link to the right place.
+ push @$parts, $p->{base_url} if $p->{base_url};
+ $parts;
} @{$c->stash->{pins}};
my $list_html = $c->render_fragment($template);
diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm
index 340c930c2..71dcf8e27 100644
--- a/perllib/FixMyStreet/App/Controller/Root.pm
+++ b/perllib/FixMyStreet/App/Controller/Root.pm
@@ -39,8 +39,11 @@ sub auto : Private {
# decide which cobrand this request should use
$c->setup_request();
+ $c->forward('check_password_expiry');
$c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed');
+ $c->forward('/offline/_stash_manifest_theme', [ $c->cobrand->moniker ]);
+
return 1;
}
@@ -122,7 +125,9 @@ sub page_error_410_gone : Private {
sub page_error_403_access_denied : Private {
my ( $self, $c, $error_msg ) = @_;
- $c->detach('page_error', [ $error_msg || _("Sorry, you don't have permission to do that."), 403 ]);
+ $c->stash->{title} = _('Access denied');
+ $error_msg ||= _("Sorry, you don't have permission to do that.");
+ $c->detach('page_error', [ $error_msg, 403 ]);
}
sub page_error_400_bad_request : Private {
@@ -156,14 +161,30 @@ sub check_login_required : Private {
}x;
return if $c->request->path =~ $whitelist;
- # Blacklisted URLs immediately 404
- # This is primarily to work around a Safari bug where the appcache
- # URL is requested in an infinite loop if it returns a 302 redirect.
- $c->detach('/page_error_404_not_found', []) if $c->request->path =~ /^offline/;
-
$c->detach( '/auth/redirect' );
}
+sub check_password_expiry : Private {
+ my ($self, $c) = @_;
+
+ return unless $c->user_exists;
+
+ return if $c->action eq $c->controller('JS')->action_for('translation_strings');
+ return if $c->controller eq $c->controller('Auth');
+
+ my $expiry = $c->cobrand->call_hook('password_expiry');
+ return unless $expiry;
+
+ my $last_change = $c->user->get_extra_metadata('last_password_change') || 0;
+ my $midnight = int(time()/86400)*86400;
+ my $expired = $last_change + $expiry < $midnight;
+ return unless $expired;
+
+ my $uri = $c->uri_for('/auth/expired');
+ $c->res->redirect( $uri );
+ $c->detach;
+}
+
=head2 end
Attempt to render a view, if needed.
diff --git a/perllib/FixMyStreet/App/Controller/Rss.pm b/perllib/FixMyStreet/App/Controller/Rss.pm
index 443e45b93..55b3088e7 100755
--- a/perllib/FixMyStreet/App/Controller/Rss.pm
+++ b/perllib/FixMyStreet/App/Controller/Rss.pm
@@ -186,6 +186,7 @@ sub generate : Private {
$c->stash->{rss} = new XML::RSS(
version => '2.0',
encoding => 'UTF-8',
+ stylesheet => '/rss/xsl',
encode_output => undef
);
$c->stash->{rss}->add_module(
@@ -222,8 +223,11 @@ sub query_main : Private {
# FIXME Do this in a nicer way at some point in the future...
my $query = 'select * from ' . $alert_type->item_table . ' where '
. ($alert_type->head_table ? $alert_type->head_table . '_id=? and ' : '')
- . $alert_type->item_where . ' order by '
- . $alert_type->item_order;
+ . $alert_type->item_where . ' ';
+ if ($c->cobrand->can('problems_sql_restriction')) {
+ $query .= $c->cobrand->problems_sql_restriction($alert_type->item_table);
+ }
+ $query .= ' order by ' . $alert_type->item_order;
my $rss_limit = FixMyStreet->config('RSS_LIMIT');
$query .= " limit $rss_limit" unless $c->stash->{type} =~ /^all/;
@@ -298,9 +302,8 @@ sub add_row : Private {
$item{description} .= encode_entities("\n<br>$address") if $address;
}
- my $recipient_name = $c->cobrand->contact_name;
$item{description} .= encode_entities("\n<br><a href='$url'>" .
- sprintf(_("Report on %s"), $recipient_name) . "</a>");
+ sprintf(_("Report on %s"), $c->stash->{site_name}) . "</a>");
if ($row->{latitude} || $row->{longitude}) {
$item{georss} = { point => "$row->{latitude} $row->{longitude}" };
@@ -328,6 +331,7 @@ sub add_parameters : Private {
foreach ( keys %{ $c->stash->{title_params} } ) {
$row->{$_} = $c->stash->{title_params}->{$_};
}
+ $row->{SITE_NAME} = $c->stash->{site_name};
(my $title = _($alert_type->head_title)) =~ s/\{\{(.*?)}}/$row->{$1}/g;
(my $link = $alert_type->head_link) =~ s/\{\{(.*?)}}/$row->{$1}/g;
@@ -377,6 +381,20 @@ sub redirect_lat_lon : Private {
$c->res->redirect( "/rss/l/$lat,$lon" . $d_str . $state_qs );
}
+sub xsl : Path {
+ my ($self, $c) = @_;
+
+ my @include_path = @{ $c->cobrand->path_to_email_templates($c->stash->{lang_code}) };
+ my $vars = {
+ %{ $c->stash },
+ additional_template_paths => \@include_path,
+ };
+ my $body = $c->view('Email')->render($c, 'xsl.xsl', $vars);
+
+ $c->response->header('Content-Type' => 'text/xml; charset=utf-8');
+ $c->response->body($body);
+}
+
=head1 AUTHOR
Matthew Somerville
diff --git a/perllib/FixMyStreet/App/Controller/Status.pm b/perllib/FixMyStreet/App/Controller/Status.pm
index 57c8f362e..e56a7930a 100755
--- a/perllib/FixMyStreet/App/Controller/Status.pm
+++ b/perllib/FixMyStreet/App/Controller/Status.pm
@@ -31,7 +31,7 @@ sub index : Path : Args(0) {
# superusers. It doesn't have anything sensitive
$c->stash->{admin_type} = 'super';
# Fetch summary stats from admin front page
- $c->forward('/admin/index');
+ $c->forward('/admin/stats/gather');
# Fetch git version
$c->forward('/admin/config_page');
diff --git a/perllib/FixMyStreet/App/Controller/Test.pm b/perllib/FixMyStreet/App/Controller/Test.pm
new file mode 100644
index 000000000..5ec4bebf3
--- /dev/null
+++ b/perllib/FixMyStreet/App/Controller/Test.pm
@@ -0,0 +1,60 @@
+package FixMyStreet::App::Controller::Test;
+use Moose;
+use namespace::autoclean;
+
+use File::Basename;
+
+BEGIN { extends 'Catalyst::Controller'; }
+
+=head1 NAME
+
+FixMyStreet::App::Controller::Test - Catalyst Controller
+
+=head1 DESCRIPTION
+
+Test-helping Catalyst Controller.
+
+=head1 METHODS
+
+=over 4
+
+=item auto
+
+Makes sure this controller is only available when run in test.
+
+=cut
+
+sub auto : Private {
+ my ($self, $c) = @_;
+ $c->detach( '/page_error_404_not_found' ) unless FixMyStreet->test_mode;
+ return 1;
+}
+
+=item setup
+
+Sets up a particular browser test.
+
+=cut
+
+sub setup : Path('/_test/setup') : Args(1) {
+ my ( $self, $c, $test ) = @_;
+ if ($test eq 'regression-duplicate-hide') {
+ my $problem = FixMyStreet::DB->resultset("Problem")->find(1);
+ $problem->update({ category => 'Skips' });
+ $c->response->body("OK");
+ }
+}
+
+sub teardown : Path('/_test/teardown') : Args(1) {
+ my ( $self, $c, $test ) = @_;
+ if ($test eq 'regression-duplicate-hide') {
+ my $problem = FixMyStreet::DB->resultset("Problem")->find(1);
+ $problem->update({ category => 'Potholes' });
+ $c->response->body("OK");
+ }
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Controller/Tokens.pm b/perllib/FixMyStreet/App/Controller/Tokens.pm
index 659d763de..c4e601a85 100644
--- a/perllib/FixMyStreet/App/Controller/Tokens.pm
+++ b/perllib/FixMyStreet/App/Controller/Tokens.pm
@@ -185,9 +185,7 @@ sub alert_to_reporter : Path('/R') {
my $problem = $c->model('DB::Problem')->find( { id => $problem_id } )
|| $c->detach('token_error');
- $c->detach('token_too_old') if $auth_token->created < DateTime->now->subtract( months => 1 );
-
- $c->flash->{alert_to_reporter} = 1;
+ $c->flash->{alert_to_reporter} = $problem->id;
my $report_uri = $c->cobrand->base_url_for_report( $problem ) . $problem->url;
$c->res->redirect($report_uri);
}
diff --git a/perllib/FixMyStreet/App/Form/I18N.pm b/perllib/FixMyStreet/App/Form/I18N.pm
new file mode 100644
index 000000000..b37f7ac53
--- /dev/null
+++ b/perllib/FixMyStreet/App/Form/I18N.pm
@@ -0,0 +1,13 @@
+package FixMyStreet::App::Form::I18N;
+
+use Moo;
+
+sub maketext {
+ my ($self, $msg, @args) = @_;
+
+ no if ($] >= 5.022), warnings => 'redundant';
+ return sprintf(_($msg), @args);
+}
+
+1;
+
diff --git a/perllib/FixMyStreet/App/Form/ManifestTheme.pm b/perllib/FixMyStreet/App/Form/ManifestTheme.pm
new file mode 100644
index 000000000..aa2d467d6
--- /dev/null
+++ b/perllib/FixMyStreet/App/Form/ManifestTheme.pm
@@ -0,0 +1,68 @@
+package FixMyStreet::App::Form::ManifestTheme;
+
+use Path::Tiny;
+use File::Copy;
+use Digest::SHA qw(sha1_hex);
+use File::Basename;
+use HTML::FormHandler::Moose;
+use FixMyStreet::App::Form::I18N;
+use List::MoreUtils qw(uniq);
+extends 'HTML::FormHandler::Model::DBIC';
+use namespace::autoclean;
+
+has 'cobrand' => ( isa => 'Str', is => 'ro' );
+
+has '+widget_name_space' => ( default => sub { ['FixMyStreet::App::Form::Widget'] } );
+has '+widget_tags' => ( default => sub { { wrapper_tag => 'p' } } );
+has '+item_class' => ( default => 'ManifestTheme' );
+has_field 'cobrand' => ( type => 'Select', empty_select => 'Select a cobrand', required => 1 );
+has_field 'name' => ( required => 1 );
+has_field 'short_name' => ( required => 1 );
+has_field 'background_colour' => ( required => 0 );
+has_field 'theme_colour' => ( required => 0 );
+has_field 'icon' => ( required => 0, type => 'Upload', label => "Add icon" );
+has_field 'delete_icon' => ( type => 'Multiple' );
+
+sub _build_language_handle { FixMyStreet::App::Form::I18N->new }
+
+sub options_cobrand {
+ my @cobrands = uniq sort map { $_->{moniker} } FixMyStreet::Cobrand->available_cobrand_classes;
+ return map { $_ => $_ } @cobrands;
+}
+
+sub validate {
+ my $self = shift;
+
+ my $value = $self->value;
+ my $cobrand = $value->{cobrand} || $self->cobrand;
+ my $upload = $value->{icon};
+
+ if ( $upload ) {
+ if( $upload->type !~ /^image/ ) {
+ $self->field('icon')->add_error( _("File type not recognised. Please upload an image.") );
+ return;
+ }
+
+ my $uri = '/theme/' . $cobrand;
+ my $theme_path = path(FixMyStreet->path_to('web' . $uri));
+ $theme_path->mkpath;
+ FixMyStreet::PhotoStorage::base64_decode_upload(undef, $upload);
+ my ($p, $n, $ext) = fileparse($upload->filename, qr/\.[^.]*/);
+ my $key = sha1_hex($upload->slurp) . $ext;
+ my $out = path($theme_path, $key);
+ unless (copy($upload->tempname, $out)) {
+ $self->field('icon')->add_error( _("Sorry, we couldn't save your file(s), please try again.") );
+ return;
+ }
+ }
+
+ foreach my $delete_icon ( @{ $value->{delete_icon} } ) {
+ unlink FixMyStreet->path_to('web', $delete_icon);
+ }
+
+ return 1;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Form/ResponsePriority.pm b/perllib/FixMyStreet/App/Form/ResponsePriority.pm
new file mode 100644
index 000000000..9182bd7a1
--- /dev/null
+++ b/perllib/FixMyStreet/App/Form/ResponsePriority.pm
@@ -0,0 +1,50 @@
+package FixMyStreet::App::Form::ResponsePriority;
+
+use HTML::FormHandler::Moose;
+use FixMyStreet::App::Form::I18N;
+extends 'HTML::FormHandler::Model::DBIC';
+use namespace::autoclean;
+
+has 'body_id' => ( isa => 'Int', is => 'ro' );
+
+has '+widget_name_space' => ( default => sub { ['FixMyStreet::App::Form::Widget'] } );
+has '+widget_tags' => ( default => sub { { wrapper_tag => 'p' } } );
+has '+item_class' => ( default => 'ResponsePriority' );
+has_field 'name' => ( required => 1 );
+has_field 'description';
+has_field 'external_id' => ( label => 'External ID' );
+has_field 'is_default' => (
+ type => 'Checkbox',
+ option_label => 'Default priority',
+ do_label => 0,
+);
+has_field 'deleted' => (
+ type => 'Checkbox',
+ option_label => 'Flag as deleted',
+ do_label => 0,
+);
+has_field 'contacts' => (
+ type => 'Multiple',
+ widget => 'CheckboxGroup',
+ ul_class => 'no-bullets no-margin',
+ do_label => 0,
+ do_wrapper => 0,
+ tags => { inline => 1 },
+);
+
+before 'update_model' => sub {
+ my $self = shift;
+ $self->item->body_id($self->body_id);
+};
+
+sub _build_language_handle { FixMyStreet::App::Form::I18N->new }
+
+has '+unique_messages' => (
+ default => sub {
+ { response_priorities_body_id_name_key => "Names must be unique" };
+ }
+);
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Form/Role.pm b/perllib/FixMyStreet/App/Form/Role.pm
new file mode 100644
index 000000000..0b0d20703
--- /dev/null
+++ b/perllib/FixMyStreet/App/Form/Role.pm
@@ -0,0 +1,67 @@
+package FixMyStreet::App::Form::Role;
+
+use HTML::FormHandler::Moose;
+use FixMyStreet::App::Form::I18N;
+extends 'HTML::FormHandler::Model::DBIC';
+use namespace::autoclean;
+
+has 'body_id' => ( isa => 'Int', is => 'ro' );
+
+has '+widget_name_space' => ( default => sub { ['FixMyStreet::App::Form::Widget'] } );
+has '+widget_tags' => ( default => sub { { wrapper_tag => 'p' } } );
+has '+item_class' => ( default => 'Role' );
+has_field 'name' => ( required => 1 );
+has_field 'body' => ( type => 'Select', empty_select => 'Select a body', required => 1 );
+has_field 'permissions' => (
+ type => 'Multiple',
+ widget => 'CheckboxGroup',
+ ul_class => 'permissions-checkboxes',
+ tags => { inline => 1, wrapper_tag => 'fieldset', },
+);
+
+before 'update_model' => sub {
+ my $self = shift;
+ $self->item->body_id($self->body_id) if $self->body_id;
+};
+
+sub _build_language_handle { FixMyStreet::App::Form::I18N->new }
+
+has '+unique_messages' => (
+ default => sub {
+ { roles_body_id_name_key => "Role names must be unique" };
+ }
+);
+
+sub validate {
+ my $self = shift;
+
+ my $rs = $self->resultset;
+ my $value = $self->value;
+
+ return 0 if $self->body_id; # The core validation catches this, because body_id is set on $self->item
+ return 0 if $self->item_id && $self->item->body_id == $value->{body}; # Correctly caught by core validation
+
+ # Okay, due to a bug we need to check this ourselves
+ # https://github.com/gshank/html-formhandler-model-dbic/issues/20
+ my @id_clause = ();
+ @id_clause = HTML::FormHandler::Model::DBIC::_id_clause( $rs, $self->item_id ) if defined $self->item;
+
+ my %form_columns = (body => 'body_id', name => 'name');
+ my %where = map { $form_columns{$_} =>
+ exists( $value->{$_} ) ? $value->{$_} : undef ||
+ ( $self->item ? $self->item->get_column($form_columns{$_}) : undef )
+ } keys %form_columns;
+
+ my $count = $rs->search( \%where )->search( {@id_clause} )->count;
+ return 0 if $count < 1;
+
+ my $field = $self->field('name');
+ my $constraint = 'roles_body_id_name_key';
+ my $field_error = $self->unique_message_for_constraint($constraint);
+ $field->add_error( $field_error, $constraint );
+ return 1;
+}
+
+__PACKAGE__->meta->make_immutable;
+
+1;
diff --git a/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm b/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm
new file mode 100644
index 000000000..e755f1c11
--- /dev/null
+++ b/perllib/FixMyStreet/App/Form/Widget/Field/CheckboxGroup.pm
@@ -0,0 +1,64 @@
+package FixMyStreet::App::Form::Widget::Field::CheckboxGroup;
+
+use Moose::Role;
+with 'HTML::FormHandler::Widget::Field::CheckboxGroup';
+use namespace::autoclean;
+
+has ul_class => ( is => 'ro' );
+
+sub render_element {
+ my ( $self, $result ) = @_;
+ $result ||= $self->result;
+
+ my $output = '<ul class="' . ($self->ul_class || '') . '">';
+ foreach my $option ( @{ $self->{options} } ) {
+ if ( my $label = $option->{group} ) {
+ $label = $self->_localize( $label ) if $self->localize_labels;
+ $output .= qq{\n<li>$label\n<ul class="no-margin no-bullets">};
+ $output .= qq{\n<li>(<a href="#" data-select-all>} . _('all') . '</a> / ';
+ $output .= '<a href="#" data-select-none>' . _('none') . '</a>)</li>';
+ foreach my $group_opt ( @{ $option->{options} } ) {
+ $output .= '<li>';
+ $output .= $self->render_option( $group_opt, $result );
+ $output .= "</li>\n";
+ }
+ $output .= qq{</ul>\n</li>};
+ }
+ else {
+ $output .= '<li>' . $self->render_option( $option, $result ) . '</li>';
+ }
+ }
+ $output .= '</ul>';
+ $self->reset_options_index;
+ return $output;
+}
+
+1;
+
+__END__
+
+=pod
+
+=encoding UTF-8
+
+=head1 NAME
+
+FixMyStreet::App::Form::Widget::Field::CheckboxGroup - checkbox group field role
+
+=head1 SYNOPSIS
+
+Subclass of HTML::FormHandler::Widget::Field::CheckboxGroup, but printed
+as a nested <ul>.
+
+=head1 AUTHOR
+
+FormHandler Contributors - see HTML::FormHandler
+
+=head1 COPYRIGHT AND LICENSE
+
+This software is copyright (c) 2017 by Gerda Shank.
+
+This is free software; you can redistribute it and/or modify it under
+the same terms as the Perl 5 programming language system itself.
+
+=cut
diff --git a/perllib/FixMyStreet/App/Model/PhotoSet.pm b/perllib/FixMyStreet/App/Model/PhotoSet.pm
index 58b352c73..76a287e71 100644
--- a/perllib/FixMyStreet/App/Model/PhotoSet.pm
+++ b/perllib/FixMyStreet/App/Model/PhotoSet.pm
@@ -8,7 +8,6 @@ use Scalar::Util 'openhandle', 'blessed';
use Image::Size;
use IPC::Cmd qw(can_run);
use IPC::Open3;
-use MIME::Base64;
use FixMyStreet;
use FixMyStreet::ImageMagick;
@@ -121,23 +120,8 @@ has ids => ( # Arrayref of $fileid tuples (always, so post upload/raw data proc
return ();
}
- # base64 decode the file if it's encoded that way
- # Catalyst::Request::Upload doesn't do this automatically
- # unfortunately.
- my $transfer_encoding = $upload->headers->header('Content-Transfer-Encoding');
- if (defined $transfer_encoding && $transfer_encoding eq 'base64') {
- my $decoded = decode_base64($upload->slurp);
- if (open my $fh, '>', $upload->tempname) {
- binmode $fh;
- print $fh $decoded;
- close $fh
- } else {
- my $c = $self->c;
- $c->log->info('Couldn\'t open temp file to save base64 decoded image: ' . $!);
- $c->stash->{photo_error} = _("Sorry, we couldn't save your image(s), please try again.");
- return ();
- }
- }
+ # Make sure any base64 encoding is handled.
+ FixMyStreet::PhotoStorage::base64_decode_upload($self->c, $upload);
# get the photo into a variable
my $photo_blob = eval {
@@ -208,7 +192,6 @@ sub get_image_data {
my $image = $self->get_raw_image( $num )
or return;
- my $photo = $image->{data};
my $size = $args{size};
@@ -217,25 +200,30 @@ sub get_image_data {
return $image;
}
- my $im = FixMyStreet::ImageMagick->new(blob => $photo);
+ my $im = FixMyStreet::ImageMagick->new(blob => $image->{data});
+ my $photo;
if ( $size eq 'tn' ) {
- $photo = $im->shrink('x100')->as_blob;
+ $photo = $im->shrink('x100');
} elsif ( $size eq 'fp' ) {
- $photo = $im->crop->as_blob;
+ $photo = $im->crop;
+ } elsif ( $size eq 'og' ) {
+ $photo = $im->crop('1200x630');
} elsif ( $size eq 'full' ) {
- # do nothing
+ $photo = $im
} else {
- $photo = $im->shrink($args{default} || '250x250')->as_blob;
+ $photo = $im->shrink($args{default} || '250x250');
}
return {
- data => $photo,
+ data => $photo->as_blob,
+ width => $photo->width,
+ height => $photo->height,
content_type => $image->{content_type},
};
}
sub delete_cached {
- my ($self) = @_;
+ my ($self, %params) = @_;
my $object = $self->object or return;
my $id = $object->id or return;
@@ -256,6 +244,11 @@ sub delete_cached {
unlink FixMyStreet->path_to(@dirs, "$id.$i$size.$type");
}
}
+
+ # Loop through all the updates as well if requested
+ if ($params{plus_updates}) {
+ $_->get_photoset->delete_cached() foreach $object->comments->all;
+ }
}
sub remove_images {
diff --git a/perllib/FixMyStreet/App/View/EmailText.pm b/perllib/FixMyStreet/App/View/EmailText.pm
new file mode 100755
index 000000000..6b28ca13f
--- /dev/null
+++ b/perllib/FixMyStreet/App/View/EmailText.pm
@@ -0,0 +1,29 @@
+package FixMyStreet::App::View::EmailText;
+use base 'Catalyst::View::TT';
+
+use strict;
+use warnings;
+
+use FixMyStreet;
+use FixMyStreet::Template;
+
+__PACKAGE__->config(
+ CLASS => 'FixMyStreet::Template',
+ TEMPLATE_EXTENSION => '.txt',
+ INCLUDE_PATH => [ FixMyStreet->path_to( 'templates', 'email', 'default' ) ],
+ render_die => 1,
+ disable_autoescape => 1,
+);
+
+=head1 NAME
+
+FixMyStreet::App::View::EmailText - TT View for FixMyStreet::App
+
+=head1 DESCRIPTION
+
+A TT view for the text part of emails - so no HTML auto-escaping
+
+=cut
+
+1;
+
diff --git a/perllib/FixMyStreet/App/View/Web.pm b/perllib/FixMyStreet/App/View/Web.pm
index 93aa0e2fb..1e1b50094 100644
--- a/perllib/FixMyStreet/App/View/Web.pm
+++ b/perllib/FixMyStreet/App/View/Web.pm
@@ -6,6 +6,7 @@ use warnings;
use FixMyStreet;
use FixMyStreet::Template;
+use FixMyStreet::Template::SafeString;
use Utils;
__PACKAGE__->config(
@@ -19,6 +20,7 @@ __PACKAGE__->config(
'tprintf', 'prettify_dt',
'version', 'decode',
'prettify_state',
+ 'mark_safe',
],
FILTERS => {
add_links => \&add_links,
@@ -59,7 +61,15 @@ sprintf (different name to avoid clash)
sub tprintf {
my ( $self, $c, $format, @args ) = @_;
@args = @{$args[0]} if ref $args[0] eq 'ARRAY';
- return sprintf $format, @args;
+ #$format = $format->plain if UNIVERSAL::isa($format, 'Template::HTML::Variable');
+ my $s = sprintf $format, @args;
+ return FixMyStreet::Template::SafeString->new($s);
+}
+
+sub mark_safe {
+ my ($self, $c, $s) = @_;
+ $s = $s->plain if UNIVERSAL::isa($s, 'FixMyStreet::Template::Variable');
+ return FixMyStreet::Template::SafeString->new($s);
}
=head2 Utils::prettify_dt
@@ -82,16 +92,16 @@ sub prettify_dt {
[% text | add_links | html_para %]
-Add some links to some text (and thus HTML-escapes the other text.
+Add some links to some text (and thus HTML-escapes the other text).
=cut
sub add_links {
my $text = shift;
+ $text = FixMyStreet::Template::conditional_escape($text);
$text =~ s/\r//g;
- $text = FixMyStreet::Template::html_filter($text);
$text =~ s{(https?://)([^\s]+)}{"<a href=\"$1$2\">$1" . _space_slash($2) . '</a>'}ge;
- return $text;
+ return FixMyStreet::Template::SafeString->new($text);
}
sub _space_slash {
@@ -113,7 +123,7 @@ sub markup_factory {
my $text = shift;
return $text unless $user && ($user->from_body || $user->is_superuser);
$text =~ s{\*(\S.*?\S)\*}{<i>$1</i>};
- $text;
+ FixMyStreet::Template::SafeString->new($text);
}
}
diff --git a/perllib/FixMyStreet/Auth/GoogleAuth.pm b/perllib/FixMyStreet/Auth/GoogleAuth.pm
new file mode 100644
index 000000000..ffe58b2dd
--- /dev/null
+++ b/perllib/FixMyStreet/Auth/GoogleAuth.pm
@@ -0,0 +1,27 @@
+package FixMyStreet::Auth::GoogleAuth;
+
+use parent 'Auth::GoogleAuth';
+
+use strict;
+use warnings;
+use Image::PNG::QRCode 'qrpng';
+use URI;
+
+# Overridden to return a data: URI of the image
+sub qr_code {
+ my $self = shift;
+ my ( $secret32, $key_id, $issuer, $return_otpauth ) = @_;
+
+ # Make issuer a bit nicer to read
+ $issuer =~ s{https?://}{};
+
+ my $otpauth = $self->SUPER::qr_code($secret32, $key_id, $issuer, 1);
+ return $otpauth if $return_otpauth;
+
+ my $u = URI->new('data:');
+ $u->media_type('image/png');
+ $u->data(qrpng(text => $otpauth));
+ return $u;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/BathNES.pm b/perllib/FixMyStreet/Cobrand/BathNES.pm
index 773edd3c3..06095734b 100644
--- a/perllib/FixMyStreet/Cobrand/BathNES.pm
+++ b/perllib/FixMyStreet/Cobrand/BathNES.pm
@@ -6,6 +6,7 @@ use warnings;
use Moo;
with 'FixMyStreet::Roles::ConfirmValidation';
+with 'FixMyStreet::Roles::ConfirmOpen311';
use LWP::Simple;
use URI;
@@ -17,33 +18,29 @@ sub council_area { return 'Bath and North East Somerset'; }
sub council_name { return 'Bath and North East Somerset Council'; }
sub council_url { return 'bathnes'; }
-sub contact_email {
- my $self = shift;
- return join( '@', 'councilconnect_rejections', 'bathnes.gov.uk' );
-}
-
-sub suggest_duplicates { 1 }
-
sub admin_user_domain { 'bathnes.gov.uk' }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fix.bathnes.gov.uk';
-}
-
-sub map_type { 'BathNES' }
+sub map_type { 'OSM' }
sub on_map_default_status { 'open' }
-sub example_places {
- return ( 'BA1 1JQ', "Lansdown Grove" );
-}
-
sub get_geocoder {
return 'OSM'; # default of Bing gives poor results, let's try overriding.
}
+sub contact_extra_fields { [ 'display_name' ] }
+
+sub contact_extra_fields_validation {
+ my ($self, $contact, $errors) = @_;
+ return unless $contact->get_extra_metadata('display_name');
+
+ my @contacts = $contact->body->contacts->not_deleted->search({ id => { '!=', $contact->id } });
+ my %display_names = map { ($_->get_extra_metadata('display_name') || '') => 1 } @contacts;
+ if ($display_names{$contact->get_extra_metadata('display_name')}) {
+ $errors->{display_name} = 'That display name is already in use';
+ }
+}
+
sub disambiguate_location {
my $self = shift;
my $string = shift;
@@ -91,22 +88,8 @@ sub pin_colour {
sub send_questionnaires { 0 }
-sub enable_category_groups { 1 }
-
sub default_map_zoom { 3 }
-sub map_js_extra {
- my $self = shift;
-
- my $c = $self->{c};
- return unless $c->user_exists;
-
- my $banes_user = $c->user->from_body && $c->user->from_body->areas->{$self->council_area_id};
- if ( $banes_user || $c->user->is_superuser ) {
- return ['/cobrands/bathnes/staff.js'];
- }
-}
-
sub category_extra_hidden {
my ($self, $meta) = @_;
my $code = $meta->{code};
@@ -115,33 +98,6 @@ sub category_extra_hidden {
return $self->SUPER::category_extra_hidden($meta);
}
-sub open311_config {
- my ($self, $row, $h, $params) = @_;
-
- my $extra = $row->get_extra_fields;
- push @$extra,
- { name => 'report_url',
- value => $h->{url} },
- { name => 'title',
- value => $row->title },
- { name => 'description',
- value => $row->detail };
-
- # Reports made via FMS.com or the app probably won't have a USRN
- # value because we don't display the adopted highways layer on those
- # frontends. Instead we'll look up the closest asset from the WFS
- # service at the point we're sending the report over Open311.
- if (!$row->get_extra_field_value('site_code')) {
- if (my $usrn = $self->lookup_usrn($row)) {
- push @$extra,
- { name => 'site_code',
- value => $usrn };
- }
- }
-
- $row->set_extra_fields(@$extra);
-}
-
sub available_permissions {
my $self = shift;
@@ -155,7 +111,7 @@ sub available_permissions {
sub report_sent_confirmation_email { 'id' }
-sub lookup_usrn {
+sub lookup_site_code {
my $self = shift;
my $row = shift;
@@ -205,31 +161,23 @@ sub categories_restriction {
# send_method set to 'Email::BathNES' (to use a custom template) which must
# be show on the cobrand.
return $rs->search( { -or => [
- 'me.send_method' => undef, # Open311 categories
+ 'me.send_method' => undef, # Open311 categories, or Highways England
'me.send_method' => '', # Open311 categories that have been edited in the admin
'me.send_method' => 'Email::BathNES', # Street Light Fault
'me.send_method' => 'Blackhole', # Parks categories
] } );
}
-# Do a manual prefetch, as easier than sorting out quoting 'user'
+# Do a manual prefetch of all staff users for contributed_by lookup
sub _dashboard_user_lookup {
my $self = shift;
my $c = $self->{c};
- # Fetch all the relevant user IDs, and look them up
- my @user_ids = $c->stash->{objects_rs}->search({}, { columns => [ 'user_id' ] })->all;
- @user_ids = map { $_->user_id } @user_ids;
- @user_ids = $c->model('DB::User')->search(
- { id => { -in => \@user_ids } },
- { columns => [ 'id', 'email', 'phone' ] })->all;
-
- # Plus all staff users for contributed_by lookup
- push @user_ids, $c->model('DB::User')->search(
+ my @user_ids = $c->model('DB::User')->search(
{ from_body => { '!=' => undef } },
- { columns => [ 'id', 'email', 'phone' ] })->all;
+ { columns => [ 'id', 'email' ] })->all;
- my %user_lookup = map { $_->id => { email => $_->email, phone => $_->phone } } @user_ids;
+ my %user_lookup = map { $_->id => $_->email } @user_ids;
return \%user_lookup;
}
@@ -244,6 +192,10 @@ sub dashboard_export_updates_add_columns {
push @{$c->stash->{csv}->{columns}}, "staff_user";
push @{$c->stash->{csv}->{columns}}, "user_email";
+ $c->stash->{csv}->{objects} = $c->stash->{csv}->{objects}->search(undef, {
+ '+columns' => ['user.email'],
+ join => 'user',
+ });
my $user_lookup = $self->_dashboard_user_lookup;
$c->stash->{csv}->{extra_data} = sub {
@@ -251,11 +203,11 @@ sub dashboard_export_updates_add_columns {
my $staff_user = '';
if ( my $contributed_by = $report->get_extra_metadata('contributed_by') ) {
- $staff_user = $user_lookup->{$contributed_by}{email};
+ $staff_user = $user_lookup->{$contributed_by};
}
return {
- user_email => $user_lookup->{$report->user_id}{email} || '',
+ user_email => $report->user->email || '',
staff_user => $staff_user,
};
};
@@ -283,6 +235,10 @@ sub dashboard_export_problems_add_columns {
"attribute_data",
];
+ $c->stash->{csv}->{objects} = $c->stash->{csv}->{objects}->search(undef, {
+ '+columns' => ['user.email', 'user.phone'],
+ join => 'user',
+ });
my $user_lookup = $self->_dashboard_user_lookup;
$c->stash->{csv}->{extra_data} = sub {
@@ -290,12 +246,12 @@ sub dashboard_export_problems_add_columns {
my $staff_user = '';
if ( my $contributed_by = $report->get_extra_metadata('contributed_by') ) {
- $staff_user = $user_lookup->{$contributed_by}{email};
+ $staff_user = $user_lookup->{$contributed_by};
}
my $attribute_data = join "; ", map { $_->{name} . " = " . $_->{value} } @{ $report->get_extra_fields };
return {
- user_email => $user_lookup->{$report->user_id}{email} || '',
- user_phone => $user_lookup->{$report->user_id}{phone} || '',
+ user_email => $report->user->email || '',
+ user_phone => $report->user->phone || '',
staff_user => $staff_user,
attribute_data => $attribute_data,
};
diff --git a/perllib/FixMyStreet/Cobrand/Bexley.pm b/perllib/FixMyStreet/Cobrand/Bexley.pm
new file mode 100644
index 000000000..481926e72
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/Bexley.pm
@@ -0,0 +1,286 @@
+package FixMyStreet::Cobrand::Bexley;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+use Encode;
+use JSON::MaybeXS;
+use LWP::Simple qw($ua);
+use Path::Tiny;
+use Time::Piece;
+
+sub council_area_id { 2494 }
+sub council_area { 'Bexley' }
+sub council_name { 'London Borough of Bexley' }
+sub council_url { 'bexley' }
+sub get_geocoder { 'Bexley' }
+sub map_type { 'MasterMap' }
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ centre => '51.46088,0.142359',
+ bounds => [ 51.408484, 0.074653, 51.515542, 0.2234676 ],
+ };
+}
+
+sub disable_resend_button { 1 }
+
+# We can resend reports upon category change, unless it will be going to the
+# same Symology database, because that will reject saying it already has the
+# ID.
+sub category_change_force_resend {
+ my ($self, $old, $new) = @_;
+
+ # Get the Open311 identifiers
+ my $contacts = $self->{c}->stash->{contacts};
+ ($old) = map { $_->email } grep { $_->category eq $old } @$contacts;
+ ($new) = map { $_->email } grep { $_->category eq $new } @$contacts;
+
+ # Okay if we're switching to/from/within Confirm/Uniform
+ return 1 if $old =~ /^(Confirm|Uniform)/ || $new =~ /^(Confirm|Uniform)/;
+
+ # Otherwise, okay if we're switching between Symology DBs, but not within
+ return ($old =~ /^StreetLighting/ xor $new =~ /^StreetLighting/);
+}
+
+sub on_map_default_status { 'open' }
+
+sub open311_munge_update_params {
+ my ($self, $params, $comment, $body) = @_;
+
+ $params->{service_request_id_ext} = $comment->problem->id;
+
+ my $contact = $comment->problem->category_row;
+ $params->{service_code} = $contact->email;
+}
+
+sub open311_get_update_munging {
+ my ($self, $comment) = @_;
+
+ # If we've received an update via Open311 that's closed
+ # or fixed the report, also close it to updates.
+ $comment->problem->set_extra_metadata(closed_updates => 1)
+ if !$comment->problem->is_open;
+}
+
+sub lookup_site_code_config {
+ my ($self, $property) = @_;
+
+ # uncoverable subroutine
+ # uncoverable statement
+ {
+ buffer => 1000, # metres
+ url => "https://tilma.mysociety.org/mapserver/bexley",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "Streets",
+ property => $property,
+ accept_feature => sub { 1 }
+ }
+}
+
+sub open311_config {
+ my ($self, $row, $h, $params) = @_;
+
+ $params->{multi_photos} = 1;
+}
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra, $contact) = @_;
+
+ my $open311_only;
+ if ($contact->email =~ /^Confirm/) {
+ push @$open311_only,
+ { name => 'report_url', description => 'Report URL',
+ value => $h->{url} },
+ { name => 'title', description => 'Title',
+ value => $row->title },
+ { name => 'description', description => 'Detail',
+ value => $row->detail };
+
+ if (!$row->get_extra_field_value('site_code')) {
+ if (my $ref = $self->lookup_site_code($row, 'NSG_REF')) {
+ push @$extra, { name => 'site_code', value => $ref, description => 'Site code' };
+ }
+ }
+ } elsif ($contact->email =~ /^Uniform/) {
+ # Reports made via the app probably won't have a UPRN because we don't
+ # display the road layer. Instead we'll look up the closest asset from the
+ # WFS service at the point we're sending the report over Open311.
+ if (!$row->get_extra_field_value('uprn')) {
+ if (my $ref = $self->lookup_site_code($row, 'UPRN')) {
+ push @$extra, { name => 'uprn', description => 'UPRN', value => $ref };
+ }
+ }
+ } else { # Symology
+ # Reports made via the app probably won't have a NSGRef because we don't
+ # display the road layer. Instead we'll look up the closest asset from the
+ # WFS service at the point we're sending the report over Open311.
+ if (!$row->get_extra_field_value('NSGRef')) {
+ if (my $ref = $self->lookup_site_code($row, 'NSG_REF')) {
+ push @$extra, { name => 'NSGRef', description => 'NSG Ref', value => $ref };
+ }
+ }
+ }
+
+ return $open311_only;
+}
+
+sub admin_user_domain { 'bexley.gov.uk' }
+
+sub open311_post_send {
+ my ($self, $row, $h, $contact) = @_;
+
+ # Check Open311 was successful
+ return unless $row->external_id;
+
+ my @lighting = (
+ 'Lamp post',
+ 'Light in multi-storey car park',
+ 'Light in outside car park',
+ 'Light in park or open space',
+ 'Traffic bollard',
+ 'Traffic sign light',
+ 'Underpass light',
+ 'Zebra crossing light',
+ );
+ my %lighting = map { $_ => 1 } @lighting;
+
+ my @flooding = (
+ 'Flooding in the road',
+ 'Blocked rainwater gulleys',
+ );
+ my %flooding = map { $_ => 1 } @flooding;
+
+ my $emails = $self->feature('open311_email') || return;
+ my $dangerous = $row->get_extra_field_value('dangerous') || '';
+
+ my $p1_email = 0;
+ my $outofhours_email = 0;
+ if ($row->category eq 'Abandoned and untaxed vehicles') {
+ my $burnt = $row->get_extra_field_value('burnt') || '';
+ $p1_email = 1 if $burnt eq 'Yes';
+ } elsif ($row->category eq 'Dead animal') {
+ $p1_email = 1;
+ $outofhours_email = 1;
+ } elsif ($row->category eq 'Gulley covers' || $row->category eq 'Manhole covers') {
+ my $reportType = $row->get_extra_field_value('reportType') || '';
+ if ($reportType eq 'Cover missing' || $dangerous eq 'Yes') {
+ $p1_email = 1;
+ $outofhours_email = 1;
+ }
+ } elsif ($row->category eq 'Street cleaning and litter') {
+ my $reportType = $row->get_extra_field_value('reportType') || '';
+ if ($reportType eq 'Oil spillage' || $dangerous eq 'Yes') {
+ $p1_email = 1;
+ $outofhours_email = 1;
+ }
+ } elsif ($row->category eq 'Damage to kerb' || $row->category eq 'Damaged road' || $row->category eq 'Damaged pavement') {
+ $p1_email = 1;
+ $outofhours_email = 1;
+ } elsif (!$lighting{$row->category}) {
+ $p1_email = 1 if $dangerous eq 'Yes';
+ $outofhours_email = 1 if $dangerous eq 'Yes';
+ }
+
+ my @to;
+ my $p1_email_to_use = ($contact->email =~ /^Confirm/) ? $emails->{p1confirm} : $emails->{p1};
+ push @to, email_list($p1_email_to_use, 'Bexley P1 email') if $p1_email;
+ push @to, email_list($emails->{lighting}, 'FixMyStreet Bexley Street Lighting') if $lighting{$row->category};
+ push @to, email_list($emails->{flooding}, 'FixMyStreet Bexley Flooding') if $flooding{$row->category};
+ push @to, email_list($emails->{outofhours}, 'Bexley out of hours') if $outofhours_email && _is_out_of_hours();
+ if ($contact->email =~ /^Uniform/) {
+ push @to, email_list($emails->{eh}, 'FixMyStreet Bexley EH');
+ $row->push_extra_fields({ name => 'uniform_id', description => 'Uniform ID', value => $row->external_id });
+ }
+
+ return unless @to;
+ my $sender = FixMyStreet::SendReport::Email->new( to => \@to );
+
+ $self->open311_config($row, $h, {}, $contact); # Populate NSGRef again if needed
+
+ my $extra_data = join "; ", map { "$_->{description}: $_->{value}" } @{$row->get_extra_fields};
+ $h->{additional_information} = $extra_data;
+
+ $sender->send($row, $h);
+}
+
+sub email_list {
+ my ($emails, $name) = @_;
+ return unless $emails;
+ my @emails = split /,/, $emails;
+ my @to = map { [ $_, $name ] } @emails;
+ return @to;
+}
+
+sub dashboard_export_problems_add_columns {
+ my $self = shift;
+ my $c = $self->{c};
+
+ my %groups;
+ if ($c->stash->{body}) {
+ %groups = FixMyStreet::DB->resultset('Contact')->search({
+ body_id => $c->stash->{body}->id,
+ })->group_lookup;
+ }
+
+ splice @{$c->stash->{csv}->{headers}}, 5, 0, 'Subcategory';
+ splice @{$c->stash->{csv}->{columns}}, 5, 0, 'subcategory';
+
+ $c->stash->{csv}->{extra_data} = sub {
+ my $report = shift;
+
+ if ($groups{$report->category}) {
+ return {
+ category => $groups{$report->category},
+ subcategory => $report->category,
+ };
+ }
+ return {};
+ };
+}
+
+sub _is_out_of_hours {
+ my $time = localtime;
+ return 1 if $time->hour > 16 || ($time->hour == 16 && $time->min >= 45);
+ return 1 if $time->hour < 8;
+ return 1 if $time->wday == 1 || $time->wday == 7;
+ return 1 if _is_bank_holiday();
+ return 0;
+}
+
+sub _is_bank_holiday {
+ my $json = _get_bank_holiday_json();
+ my $today = localtime->date;
+ for my $event (@{$json->{'england-and-wales'}{events}}) {
+ if ($event->{date} eq $today) {
+ return 1;
+ }
+ }
+}
+
+sub _get_bank_holiday_json {
+ my $file = 'bank-holidays.json';
+ my $cache_file = path(FixMyStreet->path_to("../data/$file"));
+ my $js;
+ if (-s $cache_file && -M $cache_file <= 7 && !FixMyStreet->config('STAGING_SITE')) {
+ # uncoverable statement
+ $js = $cache_file->slurp_utf8;
+ } else {
+ $ua->timeout(5);
+ $js = LWP::Simple::get("https://www.gov.uk/$file");
+ # uncoverable branch false
+ $js = decode_utf8($js) if !utf8::is_utf8($js);
+ if ($js && !FixMyStreet->config('STAGING_SITE')) {
+ # uncoverable statement
+ $cache_file->spew_utf8($js);
+ }
+ }
+ $js = JSON->new->decode($js) if $js;
+ return $js;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Borsetshire.pm b/perllib/FixMyStreet/Cobrand/Borsetshire.pm
index e721bee0f..f8650169d 100644
--- a/perllib/FixMyStreet/Cobrand/Borsetshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Borsetshire.pm
@@ -9,10 +9,6 @@ sub council_area { return 'Borsetshire'; }
sub council_name { return 'Borsetshire County Council'; }
sub council_url { return 'demo'; }
-sub example_places {
- return ( 'BS36 2NS', 'Coalpit Heath' );
-}
-
sub pin_colour {
my ( $self, $p, $context ) = @_;
return 'grey' if $p->is_closed;
@@ -31,8 +27,4 @@ sub send_questionnaires {
sub bypass_password_checks { 1 }
-sub enable_category_groups { 1 }
-
-sub suggest_duplicates { 1 }
-
1;
diff --git a/perllib/FixMyStreet/Cobrand/Bristol.pm b/perllib/FixMyStreet/Cobrand/Bristol.pm
index fa2d3fabb..6e3160c89 100644
--- a/perllib/FixMyStreet/Cobrand/Bristol.pm
+++ b/perllib/FixMyStreet/Cobrand/Bristol.pm
@@ -1,5 +1,5 @@
package FixMyStreet::Cobrand::Bristol;
-use parent 'FixMyStreet::Cobrand::UKCouncils';
+use parent 'FixMyStreet::Cobrand::Whitelabel';
use strict;
use warnings;
@@ -9,16 +9,6 @@ sub council_area { return 'Bristol'; }
sub council_name { return 'Bristol County Council'; }
sub council_url { return 'bristol'; }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.bristol.gov.uk';
-}
-
-sub example_places {
- return ( 'BS1 5TR', "Broad Quay" );
-}
-
sub map_type {
'Bristol';
}
@@ -50,11 +40,6 @@ sub pin_colour {
return 'yellow';
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'customer.services', 'bristol.gov.uk' );
-}
-
sub send_questionnaires {
return 0;
}
@@ -66,6 +51,7 @@ sub categories_restriction {
# cobrand, not the email categories from FMS.com. We've set up the
# Email categories with a devolved send_method, so can identify Open311
# categories as those which have a blank send_method.
+ # Also Highways England categories have a blank send_method.
return $rs->search( { 'me.send_method' => undef } );
}
@@ -78,10 +64,15 @@ sub open311_config {
sub open311_contact_meta_override {
my ($self, $service, $contact, $meta) = @_;
+ # Bristol returns groups we do not want to use
+ $service->{group} = [];
+
my %server_set = (easting => 1, northing => 1);
foreach (@$meta) {
$_->{automated} = 'server_set' if $server_set{$_->{code}};
}
}
+sub admin_user_domain { 'bristol.gov.uk' }
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/Bromley.pm b/perllib/FixMyStreet/Cobrand/Bromley.pm
index 341fb6a30..8f82817a8 100644
--- a/perllib/FixMyStreet/Cobrand/Bromley.pm
+++ b/perllib/FixMyStreet/Cobrand/Bromley.pm
@@ -30,13 +30,10 @@ sub report_validation {
sub report_new_munge_before_insert {
my ($self, $report) = @_;
- $report->subcategory($report->get_extra_field_value('service_sub_code'));
-}
+ # Make sure TfL reports are marked safety critical
+ $self->SUPER::report_new_munge_before_insert($report);
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fix.bromley.gov.uk';
+ $report->subcategory($report->get_extra_field_value('service_sub_code'));
}
sub problems_on_map_restriction {
@@ -87,10 +84,6 @@ sub get_geocoder {
return 'OSM'; # default of Bing gives poor results, let's try overriding.
}
-sub example_places {
- return ( 'BR1 3UH', 'Glebe Rd, Bromley' );
-}
-
sub map_type {
'Bromley';
}
@@ -121,12 +114,6 @@ sub process_open311_extras {
$self->SUPER::process_open311_extras( @_, [ 'first_name', 'last_name' ] );
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'info', 'bromley.gov.uk' );
-}
-sub contact_name { 'Bromley Council (do not reply)'; }
-
sub abuse_reports_only { 1; }
sub reports_per_page { return 20; }
@@ -143,37 +130,33 @@ sub tweak_all_reports_map {
}
# A place where this can happen
- return unless $c->stash->{template} && $c->stash->{template} eq 'about/heatmap.html';
-
- my $children = $c->stash->{body}->first_area_children;
- foreach (values %$children) {
- $_->{url} = $c->uri_for( $c->stash->{body_url}
- . '/' . $c->cobrand->short_name( $_ )
- );
- }
- $c->stash->{children} = $children;
+ return unless $c->action eq 'dashboard/heatmap';
+ # Bromley uses an extra attribute question to store 'subcategory',
+ # rather than group/category, but wants this extra question to act
+ # like a subcategory e.g. in the dashboard filter here.
my %subcats = $self->subcategories;
- my $filter = $c->stash->{filter_categories};
- my @new_contacts;
- foreach (@$filter) {
- push @new_contacts, $_;
- foreach (@{$subcats{$_->id}}) {
- push @new_contacts, {
- category => $_->{key},
- category_display => (" " x 4) . $_->{name},
- };
+ my $groups = $c->stash->{category_groups};
+ foreach (@$groups) {
+ my $filter = $_->{categories};
+ my @new_contacts;
+ foreach (@$filter) {
+ push @new_contacts, $_;
+ foreach (@{$subcats{$_->id}}) {
+ push @new_contacts, {
+ category => $_->{key},
+ category_display => (" " x 4) . $_->{name},
+ };
+ }
}
+ $_->{categories} = \@new_contacts;
}
- $c->stash->{filter_categories} = \@new_contacts;
if (!%{$c->stash->{filter_category}}) {
my $cats = $c->user->categories;
my $subcats = $c->user->get_extra_metadata('subcategories') || [];
$c->stash->{filter_category} = { map { $_ => 1 } @$cats, @$subcats } if @$cats || @$subcats;
}
-
- $c->stash->{ward_hash} = { map { $_->{id} => 1 } @{$c->stash->{wards}} } if $c->stash->{wards};
}
sub title_list {
@@ -183,7 +166,14 @@ sub title_list {
sub open311_config {
my ($self, $row, $h, $params) = @_;
- my $extra = $row->get_extra_fields;
+ $params->{always_send_latlong} = 0;
+ $params->{send_notpinpointed} = 1;
+ $params->{extended_description} = 0;
+}
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
+
my $title = $row->title;
foreach (@$extra) {
@@ -191,9 +181,8 @@ sub open311_config {
$title .= ' | ID: ' . $_->{value} if $_->{name} eq 'feature_id';
$title .= ' | PROW ID: ' . $_->{value} if $_->{name} eq 'prow_reference';
}
- @$extra = grep { $_->{name} !~ /feature_id|prow_reference/ } @$extra;
- push @$extra,
+ my $open311_only = [
{ name => 'report_url',
value => $h->{url} },
{ name => 'report_title',
@@ -205,20 +194,20 @@ sub open311_config {
{ name => 'requested_datetime',
value => DateTime::Format::W3CDTF->format_datetime($row->confirmed->set_nanosecond(0)) },
{ name => 'email',
- value => $row->user->email };
+ value => $row->user->email }
+ ];
# make sure we have last_name attribute present in row's extra, so
# it is passed correctly to Bromley as attribute[]
- if ( $row->cobrand ne 'bromley' ) {
- my ( $firstname, $lastname ) = ( $row->name =~ /(\w+)\.?\s+(.+)/ );
- push @$extra, { name => 'last_name', value => $lastname };
+ if (!$row->get_extra_field_value('last_name')) {
+ my ( $firstname, $lastname ) = ( $row->name =~ /(\S+)\.?\s+(.+)/ );
+ push @$open311_only, { name => 'last_name', value => $lastname };
+ }
+ if (!$row->get_extra_field_value('fms_extra_title') && $row->user->title) {
+ push @$open311_only, { name => 'fms_extra_title', value => $row->user->title };
}
- $row->set_extra_fields(@$extra);
-
- $params->{always_send_latlong} = 0;
- $params->{send_notpinpointed} = 1;
- $params->{extended_description} = 0;
+ return ($open311_only, [ 'feature_id', 'prow_reference' ]);
}
sub open311_config_updates {
@@ -347,27 +336,14 @@ sub add_admin_subcategories {
return \@new_contacts;
}
-sub about_hook {
- my $self = shift;
- my $c = $self->{c};
-
- # Display a special custom dashboard page, with heatmap
- if ($c->stash->{template} eq 'about/heatmap.html') {
- $c->forward('/dashboard/check_page_allowed');
- # We want a special sidebar
- $c->stash->{ajax_template} = "about/heatmap-list.html";
- $c->set_param('js', 1) unless $c->get_param('ajax'); # Want to load pins client-side
- $c->forward('/reports/body', [ 'Bromley' ]);
- }
-}
-
-# On heatmap page, include querying on subcategories, wards, dates, provided
+# On heatmap page, include querying on subcategories
sub munge_load_and_group_problems {
my ($self, $where, $filter) = @_;
my $c = $self->{c};
- return unless $c->stash->{template} && $c->stash->{template} eq 'about/heatmap.html';
+ return unless $c->action eq 'dashboard/heatmap';
+ # Bromley subcategory stuff
if (!$where->{category}) {
my $cats = $c->user->categories;
my $subcats = $c->user->get_extra_metadata('subcategories') || [];
@@ -386,61 +362,6 @@ sub munge_load_and_group_problems {
};
delete $where->{category};
}
-
- # Wards
- my @areas = @{$c->user->area_ids || []};
- # Want to get everything if nothing given in an ajax call
- if (!$c->stash->{wards} && @areas) {
- $c->stash->{wards} = [ map { { id => $_ } } @areas ];
- $where->{areas} = [
- map { { 'like', '%,' . $_ . ',%' } } @areas
- ];
- }
-
- # Date range
- my $start_default = DateTime->today(time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone)->subtract(months => 1);
- $c->stash->{start_date} = $c->get_param('start_date') || $start_default->strftime('%Y-%m-%d');
- $c->stash->{end_date} = $c->get_param('end_date');
-
- my $range = FixMyStreet::DateRange->new(
- start_date => $c->stash->{start_date},
- start_default => $start_default,
- end_date => $c->stash->{end_date},
- formatter => $c->model('DB')->storage->datetime_parser,
- );
- $where->{'me.confirmed'} = $range->sql;
-
- delete $filter->{rows};
-
- # Load the relevant stuff for the sidebar as well
- my $problems = $self->problems->search($where, $filter);
-
- $c->stash->{five_newest} = [ $problems->search(undef, {
- rows => 5,
- order_by => { -desc => 'confirmed' },
- })->all ];
-
- $c->stash->{ten_oldest} = [ $problems->search({
- 'me.state' => [ FixMyStreet::DB::Result::Problem->open_states() ],
- }, {
- rows => 10,
- order_by => 'lastupdate',
- })->all ];
-
- my $params = { map { my $n = $_; s/me\./problem\./; $_ => $where->{$n} } keys %$where };
- my @c = $c->model('DB::Comment')->to_body($self->body)->search({
- %$params,
- 'me.user_id' => { -not_in => [ $c->user->id, $self->body->comment_user_id ] },
- 'me.state' => 'confirmed',
- }, {
- columns => 'problem_id',
- group_by => 'problem_id',
- order_by => { -desc => \'max(me.confirmed)' },
- rows => 5,
- })->all;
- $c->stash->{five_commented} = [ map { $_->problem } @c ];
-
- return $problems;
}
1;
diff --git a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm
index 3a33d6f58..117725273 100644
--- a/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Buckinghamshire.pm
@@ -1,38 +1,25 @@
package FixMyStreet::Cobrand::Buckinghamshire;
-use parent 'FixMyStreet::Cobrand::UKCouncils';
+use parent 'FixMyStreet::Cobrand::Whitelabel';
use strict;
use warnings;
use Moo;
+with 'FixMyStreet::Roles::ConfirmOpen311';
with 'FixMyStreet::Roles::ConfirmValidation';
+with 'FixMyStreet::Roles::BoroughEmails';
sub council_area_id { return 2217; }
sub council_area { return 'Buckinghamshire'; }
-sub council_name { return 'Buckinghamshire County Council'; }
+sub council_name { return 'Buckinghamshire Council'; }
sub council_url { return 'buckinghamshire'; }
-
-sub example_places {
- return ( 'HP19 7QF', "Walton Road" );
-}
-
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.buckscc.gov.uk';
-}
-
sub disambiguate_location {
my $self = shift;
my $string = shift;
my $town = 'Buckinghamshire';
- # The geocoder returns two results for 'Aylesbury', so force the better
- # result to be used.
- $town = "$town, HP20 2NH" if $string =~ /[\s]*aylesbury[\s]*/i;
-
return {
%{ $self->SUPER::disambiguate_location() },
town => $town,
@@ -46,50 +33,18 @@ sub on_map_default_status { 'open' }
sub pin_colour {
my ( $self, $p, $context ) = @_;
- return 'grey' if $p->state eq 'not responsible';
+ return 'grey' if $p->state eq 'not responsible' || !$self->owns_problem( $p );
return 'green' if $p->is_fixed || $p->is_closed;
return 'red' if $p->state eq 'confirmed';
return 'yellow';
}
-sub admin_user_domain { 'buckscc.gov.uk' }
-
-sub contact_email {
- my $self = shift;
- return join( '@', 'fixmystreetbs', 'email.buckscc.gov.uk' );
-}
+sub admin_user_domain { ( 'buckscc.gov.uk', 'buckinghamshire.gov.uk' ) }
sub send_questionnaires {
return 0;
}
-sub open311_config {
- my ($self, $row, $h, $params) = @_;
-
- my $extra = $row->get_extra_fields;
- push @$extra,
- { name => 'report_url',
- value => $h->{url} },
- { name => 'title',
- value => $row->title },
- { name => 'description',
- value => $row->detail };
-
- # Reports made via FMS.com or the app probably won't have a site code
- # value because we don't display the adopted highways layer on those
- # frontends. Instead we'll look up the closest asset from the WFS
- # service at the point we're sending the report over Open311.
- if (!$row->get_extra_field_value('site_code')) {
- if (my $site_code = $self->lookup_site_code($row)) {
- push @$extra,
- { name => 'site_code',
- value => $site_code };
- }
- }
-
- $row->set_extra_fields(@$extra);
-}
-
sub open311_pre_send {
my ($self, $row, $open311) = @_;
@@ -108,11 +63,12 @@ sub open311_post_send {
return unless $row->external_id;
# For certain categories, send an email also
+ my $emails = $self->feature('open311_email');
my $addresses = {
- 'Flytipping' => [ join('@', 'illegaldumpingcosts', $self->admin_user_domain), "TfB" ],
- 'Blocked drain' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ],
- 'Ditch issue' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ],
- 'Flooded subway' => [ join('@', 'floodmanagement', $self->admin_user_domain), "Flood Management" ],
+ 'Flytipping' => [ $emails->{flytipping}, "TfB" ],
+ 'Blocked drain' => [ $emails->{flood}, "Flood Management" ],
+ 'Ditch issue' => [ $emails->{flood}, "Flood Management" ],
+ 'Flooded subway' => [ $emails->{flood}, "Flood Management" ],
};
my $dest = $addresses->{$row->category};
return unless $dest;
@@ -143,41 +99,15 @@ sub open311_contact_meta_override {
} if $service->{service_name} eq 'Flytipping';
}
-sub process_open311_extras {
- my ($self, $c, $body, $extra) = @_;
-
- return unless $c->stash->{report}; # Don't care about updates
-
- $self->flytipping_body_fix(
- $c->stash->{report},
- $c->get_param('road-placement'),
- $c->stash->{field_errors},
- );
-}
-
-sub flytipping_body_fix {
- my ($self, $report, $road_placement, $errors) = @_;
+sub report_new_munge_before_insert {
+ my ($self, $report) = @_;
return unless $report->category eq 'Flytipping';
- if ($report->bodies_str =~ /,/) {
- # Sent to both councils in the area
- my @bodies = values %{$report->bodies};
- my $county = (grep { $_->name =~ /^Buckinghamshire/ } @bodies)[0];
- my $district = (grep { $_->name !~ /^Buckinghamshire/ } @bodies)[0];
- # Decide which to send to based upon the answer to the extra question:
- if ($road_placement eq 'road') {
- $report->bodies_str($county->id);
- } elsif ($road_placement eq 'off-road') {
- $report->bodies_str($district->id);
- }
- } else {
- # If the report is only being sent to the district, we do
- # not care about the road question, if it is missing
- if (!$report->to_body_named('Buckinghamshire')) {
- delete $errors->{'road-placement'};
- }
- }
+ my $placement = $self->{c}->get_param('road-placement');
+ return unless $placement && $placement eq 'off-road';
+
+ $report->category('Flytipping (off-road)');
}
sub filter_report_description {
@@ -201,8 +131,6 @@ sub map_type { 'Buckinghamshire' }
sub default_map_zoom { 3 }
-sub enable_category_groups { 1 }
-
sub _dashboard_export_add_columns {
my $self = shift;
my $c = $self->{c};
@@ -456,9 +384,8 @@ sub get_geocoder { 'OSM' }
sub categories_restriction {
my ($self, $rs) = @_;
- # Buckinghamshire is a two-tier council, but only want to display
- # county-level categories on their cobrand.
- return $rs->search( [ { 'body_areas.area_id' => 2217 }, { category => 'Flytipping' } ], { join => { body => 'body_areas' } });
+
+ return $rs->search( { category => { '!=', 'Flytipping (off-road)'} } );
}
sub lookup_site_code_config { {
@@ -479,4 +406,23 @@ sub lookup_site_code_config { {
}
} }
+around 'munge_sendreport_params' => sub {
+ my ($orig, $self, $row, $h, $params) = @_;
+
+ # The district areas don't exist in MapIt past generation 36, so look up
+ # what district this report would have been in and temporarily override
+ # the areas column so BoroughEmails::munge_sendreport_params can do its
+ # thing.
+ my ($lat, $lon) = ($row->latitude, $row->longitude);
+ my $district = FixMyStreet::MapIt::call( 'point', "4326/$lon,$lat", type => 'DIS', generation => 36 );
+ ($district) = keys %$district;
+
+ my $original_areas = $row->areas;
+ $row->areas(",$district,");
+
+ $self->$orig($row, $h, $params);
+
+ $row->areas($original_areas);
+};
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/CheshireEast.pm b/perllib/FixMyStreet/Cobrand/CheshireEast.pm
new file mode 100644
index 000000000..c5e5107f3
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/CheshireEast.pm
@@ -0,0 +1,145 @@
+package FixMyStreet::Cobrand::CheshireEast;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use Moo;
+with 'FixMyStreet::Roles::ConfirmValidation';
+
+sub council_area_id { 21069 }
+sub council_area { 'Cheshire East' }
+sub council_name { 'Cheshire East Council' }
+sub council_url { 'cheshireeast' }
+
+sub pin_colour {
+ my ( $self, $p, $context ) = @_;
+ return 'grey' if $p->state eq 'not responsible' || !$self->owns_problem( $p );
+ return 'green' if $p->is_fixed || $p->is_closed;
+ return 'yellow' if $p->is_in_progress;
+ return 'red';
+}
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ centre => '53.180415,-2.349354',
+ bounds => [ 52.947150, -2.752929, 53.387445, -1.974789 ],
+ };
+}
+
+sub enter_postcode_text {
+ 'Enter a postcode, or a road and place name';
+}
+
+sub admin_user_domain { 'cheshireeast.gov.uk' }
+
+sub get_geocoder { 'OSM' }
+
+sub geocoder_munge_results {
+ my ($self, $result) = @_;
+ $result->{display_name} = '' unless $result->{display_name} =~ /Cheshire East/;
+ $result->{display_name} =~ s/, UK$//;
+ $result->{display_name} =~ s/, Cheshire East, North West England, England//;
+}
+
+sub map_type { 'CheshireEast' }
+
+sub default_map_zoom { 3 }
+
+sub on_map_default_status { 'open' }
+
+sub abuse_reports_only { 1 }
+
+sub send_questionnaires { 0 }
+
+sub open311_config {
+ my ($self, $row, $h, $params) = @_;
+
+ $params->{multi_photos} = 1;
+}
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
+
+ my $open311_only = [
+ { name => 'report_url',
+ value => $h->{url} },
+ { name => 'title',
+ value => $row->title },
+ { name => 'description',
+ value => $row->detail },
+ ];
+
+ # Reports made via FMS.com or the app probably won't have a site code
+ # value because we don't display the adopted highways layer on those
+ # frontends. Instead we'll look up the closest asset from the WFS
+ # service at the point we're sending the report over Open311.
+ if (!$row->get_extra_field_value('site_code')) {
+ if (my $site_code = $self->lookup_site_code($row)) {
+ push @$extra,
+ { name => 'site_code',
+ value => $site_code };
+ }
+ }
+
+ return $open311_only;
+}
+
+# TODO These values may not be accurate
+sub lookup_site_code_config { {
+ buffer => 200, # metres
+ url => "https://tilma.mysociety.org/mapserver/cheshireeast",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "AdoptedRoads",
+ property => "site_code",
+ accept_feature => sub { 1 }
+} }
+
+sub council_rss_alert_options {
+ my $self = shift;
+ my $all_areas = shift;
+ my $c = shift;
+
+ my %councils = map { $_ => 1 } @{$self->area_types};
+
+ my @options;
+
+ my $body = $self->body;
+
+ my ($council, $ward);
+ foreach (values %$all_areas) {
+ if ($_->{type} eq 'UTA') {
+ $council = $_;
+ $council->{id} = $body->id; # Want to use body ID, not MapIt area ID
+ $council->{short_name} = $self->short_name( $council );
+ ( $council->{id_name} = $council->{short_name} ) =~ tr/+/_/;
+ } else {
+ $ward = $_;
+ $ward->{short_name} = $self->short_name( $ward );
+ ( $ward->{id_name} = $ward->{short_name} ) =~ tr/+/_/;
+ }
+ }
+
+ push @options, {
+ type => 'council',
+ id => sprintf( 'council:%s:%s', $council->{id}, $council->{id_name} ),
+ text => 'All reported problems within the council',
+ rss_text => sprintf( 'RSS feed of problems within %s', $council->{name}),
+ uri => $c->uri_for( '/rss/reports/' . $council->{short_name} ),
+ };
+ push @options, {
+ type => 'ward',
+ id => sprintf( 'ward:%s:%s:%s:%s', $council->{id}, $ward->{id}, $council->{id_name}, $ward->{id_name} ),
+ rss_text => sprintf( 'RSS feed of reported problems within %s ward', $ward->{name}),
+ text => sprintf( 'Reported problems within %s ward', $ward->{name}),
+ uri => $c->uri_for( '/rss/reports/' . $council->{short_name} . '/' . $ward->{short_name} ),
+ } if $ward;
+
+ return ( \@options, undef );
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm
index a8146128e..695487268 100644
--- a/perllib/FixMyStreet/Cobrand/Default.pm
+++ b/perllib/FixMyStreet/Cobrand/Default.pm
@@ -14,6 +14,7 @@ use Digest::MD5 qw(md5_hex);
use Carp;
use mySociety::PostcodeUtil;
+use mySociety::Random;
=head1 The default cobrand
@@ -59,6 +60,37 @@ sub path_to_email_templates {
return $paths;
}
+=item feature
+
+A helper utility to let you provide per-cobrand hooks for configuration.
+Mostly useful if running a site with multiple cobrands.
+
+=cut
+
+sub feature {
+ my ($self, $feature) = @_;
+ my $features = FixMyStreet->config('COBRAND_FEATURES');
+ return unless $features && ref $features eq 'HASH';
+ return unless $features->{$feature} && ref $features->{$feature} eq 'HASH';
+ return $features->{$feature}->{$self->moniker};
+}
+
+sub csp_config {
+ FixMyStreet->config('CONTENT_SECURITY_POLICY');
+}
+
+sub add_response_headers {
+ my $self = shift;
+ # uncoverable branch true
+ return if $self->{c}->debug;
+ if (my $csp_domains = $self->csp_config) {
+ $csp_domains = '' if $csp_domains eq '1';
+ $csp_domains = join(' ', @$csp_domains) if ref $csp_domains;
+ my $csp_nonce = $self->{c}->stash->{csp_nonce} = unpack('h*', mySociety::Random::random_bytes(16, 1));
+ $self->{c}->res->header('Content-Security-Policy', "script-src 'self' 'unsafe-inline' 'nonce-$csp_nonce' $csp_domains; object-src 'none'; base-uri 'none'")
+ }
+}
+
=item password_minimum_length
Returns the minimum length a password can be set to.
@@ -488,6 +520,19 @@ allowing them to report them as offensive.
sub allow_update_reporting { return 0; }
+=item updates_disallowed
+
+Returns a boolean indicating whether updates on a particular report are allowed
+or not. Default behaviour is disallowed if "closed_updates" metadata is set.
+
+=cut
+
+sub updates_disallowed {
+ my ($self, $problem) = @_;
+ return 1 if $problem->get_extra_metadata('closed_updates');
+ return 0;
+}
+
=item geocode_postcode
Given a QUERY, return LAT/LON and/or ERROR.
@@ -632,7 +677,7 @@ sub admin_pages {
my $pages = {
'summary' => [_('Summary'), 0],
'timeline' => [_('Timeline'), 5],
- 'stats' => [_('Stats'), 8],
+ 'stats' => [_('Stats'), 8.5],
};
# There are some pages that only super users can see
@@ -640,6 +685,7 @@ sub admin_pages {
$pages->{flagged} = [ _('Flagged'), 7 ];
$pages->{states} = [ _('States'), 8 ];
$pages->{config} = [ _('Configuration'), 9];
+ $pages->{manifesttheme} = [ _('Manifest Theme'), 11];
$pages->{user_import} = [ undef, undef ];
};
# And some that need special permissions
@@ -665,6 +711,7 @@ sub admin_pages {
if ( $user->has_body_permission_to('user_edit') ) {
$pages->{reports} = [ _('Reports'), 2 ];
$pages->{users} = [ _('Users'), 6 ];
+ $pages->{roles} = [ _('Roles'), 7 ];
$pages->{user_edit} = [ undef, undef ];
}
if ( $self->allow_report_extra_fields && $user->has_body_permission_to('category_edit') ) {
@@ -719,12 +766,6 @@ sub available_permissions {
contribute_as_body => _("Create reports/updates as the council"),
default_to_body => _("Default to creating reports/updates as the council"),
view_body_contribute_details => _("See user detail for reports created as the council"),
-
- # NB this permission is special in that it can be assigned to users
- # without their from_body being set. It's included here for
- # reference, but left commented out because it's not assigned in the
- # same way as other permissions.
- # trusted => _("Trusted to make reports that don't need to be inspected"),
},
_("Users") => {
user_edit => _("Edit users' details/search for their reports"),
@@ -750,15 +791,32 @@ The MaPit types this site handles
sub area_types { FixMyStreet->config('MAPIT_TYPES') || [ 'ZZZ' ] }
sub area_types_children { FixMyStreet->config('MAPIT_TYPES_CHILDREN') || [] }
-=item contact_name, contact_email
+=item fetch_area_children
+
+Fetches the children of a particular MapIt area ID that match the current
+cobrand's area_types_children type.
+
+=cut
+
+sub fetch_area_children {
+ my ($self, $area_id) = @_;
+
+ return FixMyStreet::MapIt::call('area/children', $area_id,
+ type => $self->area_types_children
+ );
+}
+
+=item contact_name, contact_email, do_not_reply_email
Return the contact name or email for the cobranded version of the site (to be
-used in emails).
+used in emails). do_not_reply_email is used for emails you don't expect a reply
+to (for example, confirmation emails).
=cut
sub contact_name { FixMyStreet->config('CONTACT_NAME') }
sub contact_email { FixMyStreet->config('CONTACT_EMAIL') }
+sub do_not_reply_email { FixMyStreet->config('DO_NOT_REPLY_EMAIL') }
=item abuse_reports_only
@@ -1042,8 +1100,10 @@ sub never_confirm_reports { 0; }
=item allow_anonymous_reports
-If true then can have reports that are truely anonymous - i.e with no email or name. You
-need to also put details in the anonymous_account function too.
+If true then a report submission with no user details will default to the user
+given via the anonymous_account function, and create it anonymously. If set to
+'button', then this will happen only when a report_anonymously button is
+pressed in the front end, rather than whenever a username is not provided.
=cut
@@ -1069,6 +1129,18 @@ sub show_unconfirmed_reports {
0;
}
+=item enable_category_groups
+
+Whether body category groups should be displayed on the new report form. If this is
+not enabled then any groups will be ignored and a flat list of categories displayed.
+
+=cut
+
+sub enable_category_groups {
+ my $self = shift;
+ return $self->feature('category_groups');
+}
+
sub default_problem_state { 'unconfirmed' }
sub state_groups_admin {
@@ -1137,8 +1209,7 @@ Return the default geocoder from config.
=cut
sub get_geocoder {
- my ($self, $c) = @_;
- return $c->config->{GEOCODER};
+ FixMyStreet->config('GEOCODER');
}
@@ -1185,16 +1256,6 @@ sub category_extra_hidden {
return 0;
}
-=item reputation_increment_states/reputation_decrement_states
-
-Get a hashref of states that cause the reporting user's reputation to be
-incremented/decremented, if a report is changed to this state upon inspection.
-
-=cut
-
-sub reputation_increment_states { {} };
-sub reputation_decrement_states { {} };
-
sub traffic_management_options {
return [
_("Yes"),
@@ -1224,7 +1285,7 @@ sub allow_report_extra_fields { 0 }
sub social_auth_enabled {
my $self = shift;
- my $key_present = FixMyStreet->config('FACEBOOK_APP_ID') or FixMyStreet->config('TWITTER_KEY');
+ my $key_present = FixMyStreet->config('FACEBOOK_APP_ID') || FixMyStreet->config('TWITTER_KEY');
return $key_present && !$self->call_hook("social_auth_disabled");
}
diff --git a/perllib/FixMyStreet/Cobrand/EastHerts.pm b/perllib/FixMyStreet/Cobrand/EastHerts.pm
index 0e60c6b08..7ca2e67cf 100644
--- a/perllib/FixMyStreet/Cobrand/EastHerts.pm
+++ b/perllib/FixMyStreet/Cobrand/EastHerts.pm
@@ -1,5 +1,5 @@
package FixMyStreet::Cobrand::EastHerts;
-use parent 'FixMyStreet::Cobrand::UKCouncils';
+use parent 'FixMyStreet::Cobrand::Whitelabel';
use strict;
use warnings;
@@ -9,22 +9,11 @@ sub council_area { return 'East Hertfordshire'; }
sub council_name { return 'East Hertfordshire District Council'; }
sub council_url { return 'eastherts'; }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.eastherts.gov.uk';
-}
-
-
sub enter_postcode_text {
my ($self) = @_;
return 'Enter an ' . $self->council_area . ' postcode, or street name and area';
}
-sub example_places {
- return ( 'SG14 2AP', "Mangrove Road" );
-}
-
sub disambiguate_location {
my $self = shift;
my $string = shift;
@@ -46,9 +35,4 @@ sub pin_colour {
return 'yellow';
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'enquiries', 'eastherts.gov.uk' );
-}
-
1;
diff --git a/perllib/FixMyStreet/Cobrand/EastSussex.pm b/perllib/FixMyStreet/Cobrand/EastSussex.pm
new file mode 100644
index 000000000..e6c2da6c5
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/EastSussex.pm
@@ -0,0 +1,33 @@
+package FixMyStreet::Cobrand::EastSussex;
+use parent 'FixMyStreet::Cobrand::UK';
+
+use strict;
+use warnings;
+
+sub council_area_id { return 2224; }
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra, $contact) = @_;
+
+ $h->{es_original_detail} = $row->detail;
+
+ $contact = $row->category_row;
+ my $fields = $contact->get_extra_fields;
+ my $text = '';
+ for my $field ( @$fields ) {
+ if (($field->{variable} || '') eq 'true' && !$field->{automated}) {
+ my $q = $row->get_extra_field_value( $field->{code} ) || '';
+ $text .= "\n\n" . $field->{description} . "\n" . $q;
+ }
+ }
+ $row->detail($row->detail . $text);
+ return ();
+}
+
+sub open311_post_send {
+ my ($self, $row, $h, $contact) = @_;
+
+ $row->detail($h->{es_original_detail});
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm
index fb454f495..dfb511f39 100644
--- a/perllib/FixMyStreet/Cobrand/FixMyStreet.pm
+++ b/perllib/FixMyStreet/Cobrand/FixMyStreet.pm
@@ -4,13 +4,17 @@ use base 'FixMyStreet::Cobrand::UK';
use strict;
use warnings;
-use mySociety::Random;
-
use constant COUNCIL_ID_BROMLEY => 2482;
+use constant COUNCIL_ID_ISLEOFWIGHT => 2636;
sub on_map_default_status { return 'open'; }
-sub enable_category_groups { 1 }
+# Show TfL pins as grey
+sub pin_colour {
+ my ( $self, $p, $context ) = @_;
+ return 'grey' if $p->to_body_named('TfL');
+ return $self->next::method($p, $context);
+}
# Special extra
sub path_to_web_templates {
@@ -26,19 +30,121 @@ sub path_to_email_templates {
];
}
-sub add_response_headers {
- my $self = shift;
- # uncoverable branch true
- return if $self->{c}->debug;
- my $csp_nonce = $self->{c}->stash->{csp_nonce} = unpack('h*', mySociety::Random::random_bytes(16, 1));
- $self->{c}->res->header('Content-Security-Policy', "script-src 'self' www.google-analytics.com www.googleadservices.com 'unsafe-inline' 'nonce-$csp_nonce'")
-}
-
# FixMyStreet should return all cobrands
sub restriction {
return {};
}
+# FixMyStreet needs to not show TfL reports...
+sub problems_restriction {
+ my ($self, $rs) = @_;
+ my $table = ref $rs eq 'FixMyStreet::DB::ResultSet::Nearby' ? 'problem' : 'me';
+ return $rs->search({ "$table.cobrand" => { '!=' => 'tfl' } });
+}
+sub problems_sql_restriction {
+ my $self = shift;
+ return "AND cobrand != 'tfl'";
+}
+
+sub relative_url_for_report {
+ my ( $self, $report ) = @_;
+ return $report->cobrand eq 'tfl' ? FixMyStreet::Cobrand::TfL->base_url : "";
+}
+
+sub munge_around_category_where {
+ my ($self, $where) = @_;
+
+ my $user = $self->{c}->user;
+ my @iow = grep { $_->name eq 'Isle of Wight Council' } @{ $self->{c}->stash->{around_bodies} };
+ return unless @iow;
+
+ # display all the categories on Isle of Wight at the moment as there's no way to
+ # do the expand bit later as we fetch it using ajax which uses a bounding box so
+ # can't determine the body
+ $where->{send_method} = [ { '!=' => 'Triage' }, undef ];
+ return $where;
+}
+
+sub munge_reports_categories_list {
+ my ($self, $categories) = @_;
+
+ my %bodies = map { $_->body->name => $_->body } @$categories;
+ if ( $bodies{'Isle of Wight Council'} ) {
+ my $user = $self->{c}->user;
+ my $b = $bodies{'Isle of Wight Council'};
+
+ if ( $user && ( $user->is_superuser || $user->belongs_to_body( $b->id ) ) ) {
+ @$categories = grep { !$_->send_method || $_->send_method ne 'Triage' } @$categories;
+ return @$categories;
+ }
+
+ @$categories = grep { $_->send_method && $_->send_method eq 'Triage' } @$categories;
+ return @$categories;
+ }
+}
+
+sub munge_reports_area_list {
+ my ($self, $areas) = @_;
+ my $c = $self->{c};
+ if ($c->stash->{body}->name eq 'TfL') {
+ my %london_hash = map { $_ => 1 } FixMyStreet::Cobrand::TfL->london_boroughs;
+ @$areas = grep { $london_hash{$_} } @$areas;
+ }
+}
+
+sub munge_report_new_bodies {
+ my ($self, $bodies) = @_;
+
+ my %bodies = map { $_->name => 1 } values %$bodies;
+ if ( $bodies{'TfL'} ) {
+ # Presented categories vary if we're on/off a red route
+ my $tfl = FixMyStreet::Cobrand::TfL->new({ c => $self->{c} });
+ $tfl->munge_surrounding_london($bodies);
+ }
+
+ if ( $bodies{'Highways England'} ) {
+ my $c = $self->{c};
+ my $he = FixMyStreet::Cobrand::HighwaysEngland->new({ c => $c });
+ my $on_he_road = $c->stash->{on_he_road} = $he->report_new_is_on_he_road;
+
+ if (!$on_he_road) {
+ %$bodies = map { $_->id => $_ } grep { $_->name ne 'Highways England' } values %$bodies;
+ }
+ }
+}
+
+sub munge_report_new_contacts {
+ my ($self, $contacts) = @_;
+
+ my %bodies = map { $_->body->name => $_->body } @$contacts;
+
+ if ( $bodies{'Isle of Wight Council'} ) {
+ my $user = $self->{c}->user;
+ if ( $user && ( $user->is_superuser || $user->belongs_to_body( $bodies{'Isle of Wight Council'}->id ) ) ) {
+ @$contacts = grep { !$_->send_method || $_->send_method ne 'Triage' } @$contacts;
+ return;
+ }
+
+ @$contacts = grep { $_->send_method && $_->send_method eq 'Triage' } @$contacts;
+ }
+
+ if ( $bodies{'TfL'} ) {
+ # Presented categories vary if we're on/off a red route
+ my $tfl = FixMyStreet::Cobrand->get_class_for_moniker( 'tfl' )->new({ c => $self->{c} });
+ $tfl->munge_red_route_categories($contacts);
+ }
+
+}
+
+sub munge_load_and_group_problems {
+ my ($self, $where, $filter) = @_;
+
+ return unless $where->{category} && $self->{c}->stash->{body}->name eq 'Isle of Wight Council';
+
+ my $iow = FixMyStreet::Cobrand->get_class_for_moniker( 'isleofwight' )->new({ c => $self->{c} });
+ $where->{category} = $iow->expand_triage_cat_list($where->{category}, $self->{c}->stash->{body});
+}
+
sub title_list {
my $self = shift;
my $areas = shift;
@@ -114,7 +220,7 @@ sub _email_to_body {
foreach (@data) {
chomp;
my ($d, $b) = split /\|/;
- if ($d eq $domain) {
+ if ($d eq $domain || $d eq $email) {
$body = $b;
last;
}
@@ -151,7 +257,7 @@ sub about_hook {
if ($body) {
# Send confirmation email (hopefully)
$c->stash->{template} = 'auth/general.html';
- $c->detach('/auth/general');
+ $c->detach('/auth/general', []);
} else {
$c->stash->{error} = 'bad_email';
}
@@ -159,4 +265,86 @@ sub about_hook {
}
}
+sub per_body_config {
+ my ($self, $feature, $problem) = @_;
+
+ # This is a hash of council name to match, and what to do
+ my $cfg = $self->feature($feature) || {};
+
+ my $value;
+ my $body;
+ foreach (keys %$cfg) {
+ if ($problem->to_body_named($_)) {
+ $value = $cfg->{$_};
+ $body = $_;
+ last;
+ }
+ }
+ return ($value, $body);
+}
+
+sub updates_disallowed {
+ my $self = shift;
+ my ($problem) = @_;
+ my $c = $self->{c};
+
+ my ($type, $body) = $self->per_body_config('updates_allowed', $problem);
+ $type //= '';
+
+ if ($type eq 'none') {
+ return 1;
+ } elsif ($type eq 'staff') {
+ # Only staff and superusers can leave updates
+ my $staff = $c->user_exists && $c->user->from_body && $c->user->from_body->name =~ /$body/;
+ my $superuser = $c->user_exists && $c->user->is_superuser;
+ return 1 unless $staff || $superuser;
+ }
+
+ if ($type =~ /reporter/) {
+ return 1 if !$c->user_exists || $c->user->id != $problem->user->id;
+ }
+ if ($type =~ /open/) {
+ return 1 if $problem->is_fixed || $problem->is_closed;
+ }
+
+ return $self->next::method(@_);
+}
+
+sub suppress_reporter_alerts {
+ my $self = shift;
+ my $c = $self->{c};
+ my $problem = $c->stash->{report};
+ if ($problem->to_body_named('Westminster')) {
+ return 1;
+ }
+ return 0;
+}
+
+sub must_have_2fa {
+ my ($self, $user) = @_;
+ return 1 if $user->is_superuser;
+ return 1 if $user->from_body && $user->from_body->name eq 'TfL';
+ return 0;
+}
+
+sub send_questionnaire {
+ my ($self, $problem) = @_;
+ my ($send, $body) = $self->per_body_config('send_questionnaire', $problem);
+ return $send // 1;
+}
+
+sub update_email_shortlisted_user {
+ my ($self, $update) = @_;
+ FixMyStreet::Cobrand::TfL::update_email_shortlisted_user($self, $update);
+}
+
+sub manifest {
+ return {
+ related_applications => [
+ { platform => 'play', url => 'https://play.google.com/store/apps/details?id=org.mysociety.FixMyStreet', id => 'org.mysociety.FixMyStreet' },
+ { platform => 'itunes', url => 'https://apps.apple.com/gb/app/fixmystreet/id297456545', id => 'id297456545' },
+ ],
+ };
+}
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm
index d1a1980a7..d1574e1fa 100644
--- a/perllib/FixMyStreet/Cobrand/FixaMinGata.pm
+++ b/perllib/FixMyStreet/Cobrand/FixaMinGata.pm
@@ -192,4 +192,8 @@ sub body_responsiveness_threshold {
return 5;
}
+sub suggest_duplicates { 1 }
+
+sub default_show_name { 1 }
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/Greenwich.pm b/perllib/FixMyStreet/Cobrand/Greenwich.pm
index 2aaa5d776..be260d0c0 100644
--- a/perllib/FixMyStreet/Cobrand/Greenwich.pm
+++ b/perllib/FixMyStreet/Cobrand/Greenwich.pm
@@ -5,20 +5,10 @@ use strict;
use warnings;
sub council_area_id { return 2493; }
-sub council_area { return 'Greenwich'; }
+sub council_area { return 'Royal Borough of Greenwich'; }
sub council_name { return 'Royal Borough of Greenwich'; }
sub council_url { return 'greenwich'; }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fix.royalgreenwich.gov.uk';
-}
-
-sub example_places {
- return ( 'SE18 6HQ', "Woolwich Road" );
-}
-
sub enter_postcode_text {
my ($self) = @_;
return 'Enter a Royal Greenwich postcode, or street name and area';
@@ -50,25 +40,25 @@ sub pin_colour {
return 'yellow';
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'fixmystreet', 'royalgreenwich.gov.uk' );
-}
-
sub reports_per_page { return 20; }
-sub open311_config {
- my ($self, $row, $h, $params) = @_;
+sub admin_user_domain { 'royalgreenwich.gov.uk' }
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
- my $extra = $row->get_extra_fields;
# Greenwich doesn't have category metadata to fill this
- push @$extra, { name => 'external_id', value => $row->id };
- $row->set_extra_fields( @$extra );
+ return [
+ { name => 'external_id', value => $row->id },
+ ];
}
sub open311_contact_meta_override {
my ($self, $service, $contact, $meta) = @_;
+ # Greenwich returns groups we do not want to use
+ $service->{group} = [];
+
my %server_set = (easting => 1, northing => 1, closest_address => 1);
foreach (@$meta) {
$_->{automated} = 'server_set' if $server_set{$_->{code}};
diff --git a/perllib/FixMyStreet/Cobrand/Hart.pm b/perllib/FixMyStreet/Cobrand/Hart.pm
index 3ff2a2a19..24a358ab8 100644
--- a/perllib/FixMyStreet/Cobrand/Hart.pm
+++ b/perllib/FixMyStreet/Cobrand/Hart.pm
@@ -26,10 +26,6 @@ sub disambiguate_location {
};
}
-sub example_places {
- return ( 'GU51 4JX', 'Primrose Drive' );
-}
-
sub categories_restriction {
my ($self, $rs) = @_;
return $rs->search( { category => { '!=' => 'Graffiti on bridges/subways' } } );
@@ -43,12 +39,6 @@ sub ask_ever_reported {
return 0;
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'info', 'hart.gov.uk' );
-}
-sub contact_name { 'Hart District Council (do not reply)'; }
-
sub default_map_zoom { 3 }
sub reports_per_page { return 20; }
diff --git a/perllib/FixMyStreet/Cobrand/HighwaysEngland.pm b/perllib/FixMyStreet/Cobrand/HighwaysEngland.pm
new file mode 100644
index 000000000..ed58eb4f7
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/HighwaysEngland.pm
@@ -0,0 +1,158 @@
+package FixMyStreet::Cobrand::HighwaysEngland;
+use parent 'FixMyStreet::Cobrand::UK';
+
+use strict;
+use warnings;
+
+sub council_url { 'highwaysengland' }
+
+sub site_key { 'highwaysengland' }
+
+sub restriction { { cobrand => shift->moniker } }
+
+sub hide_areas_on_reports { 1 }
+
+sub all_reports_single_body { { name => 'Highways England' } }
+
+sub body {
+ my $self = shift;
+ my $body = FixMyStreet::DB->resultset('Body')->search({ name => 'Highways England' })->first;
+ return $body;
+}
+
+# Copying of functions from UKCouncils that are needed here also - factor out to a role of some sort?
+sub cut_off_date { '' }
+sub problems_restriction { FixMyStreet::Cobrand::UKCouncils::problems_restriction($_[0], $_[1]) }
+sub problems_on_map_restriction { $_[0]->problems_restriction($_[1]) }
+sub problems_sql_restriction { FixMyStreet::Cobrand::UKCouncils::problems_sql_restriction($_[0], $_[1]) }
+sub users_restriction { FixMyStreet::Cobrand::UKCouncils::users_restriction($_[0], $_[1]) }
+sub updates_restriction { FixMyStreet::Cobrand::UKCouncils::updates_restriction($_[0], $_[1]) }
+sub base_url { FixMyStreet::Cobrand::UKCouncils::base_url($_[0]) }
+
+sub admin_allow_user {
+ my ( $self, $user ) = @_;
+ return 1 if $user->is_superuser;
+ return undef unless defined $user->from_body;
+ return $user->from_body->name eq 'Highways England';
+}
+
+sub report_form_extras {
+ ( { name => 'where_hear' } )
+}
+
+sub enter_postcode_text { 'Enter a location, road name or postcode' }
+
+sub example_places {
+ my $self = shift;
+ return $self->feature('example_places') || $self->next::method();
+}
+
+sub geocode_postcode {
+ my ( $self, $s ) = @_;
+
+ if ($s =~ /^\s*[AM]\d+\s*$/i) {
+ return {
+ error => "Please be more specific about the location of the issue, eg M1, Jct 16 or A5, Towcester"
+ };
+ }
+
+ return $self->next::method($s);
+}
+
+sub lookup_by_ref_regex {
+ return qr/^\s*((?:FMS\s*)?\d+)\s*$/i;
+}
+
+sub lookup_by_ref {
+ my ($self, $ref) = @_;
+
+ if ( $ref =~ s/^\s*FMS\s*//i ) {
+ return { 'id' => $ref };
+ }
+
+ return 0;
+}
+
+sub allow_photo_upload { 0 }
+
+sub allow_anonymous_reports { 'button' }
+
+sub admin_user_domain { 'highwaysengland.co.uk' }
+
+sub anonymous_account {
+ my $self = shift;
+ return {
+ email => $self->feature('anonymous_account') . '@' . $self->admin_user_domain,
+ name => 'Anonymous user',
+ };
+}
+
+sub updates_disallowed {
+ my ($self, $problem) = @_;
+ return 1 if $problem->is_fixed || $problem->is_closed;
+ return 1 if $problem->get_extra_metadata('closed_updates');
+ return 0;
+}
+
+# Bypass photo requirement, we have none
+sub recent_photos {
+ my ( $self, $area, $num, $lat, $lon, $dist ) = @_;
+ return $self->problems->recent if $area eq 'front';
+ return [];
+}
+
+sub area_check {
+ my ( $self, $params, $context ) = @_;
+
+ my $areas = $params->{all_areas};
+ $areas = {
+ map { $_->{id} => $_ }
+ # If no country, is prefetched area and can assume is E
+ grep { ($_->{country} || 'E') eq 'E' }
+ values %$areas
+ };
+ return $areas if %$areas;
+
+ my $error_msg = 'Sorry, this site only covers England.';
+ return ( 0, $error_msg );
+}
+
+sub fetch_area_children {
+ my $self = shift;
+
+ my $areas = FixMyStreet::MapIt::call('areas', $self->area_types);
+ $areas = {
+ map { $_->{id} => $_ }
+ grep { ($_->{country} || 'E') eq 'E' }
+ values %$areas
+ };
+ return $areas;
+}
+
+sub munge_report_new_bodies {
+ my ($self, $bodies) = @_;
+ # On the cobrand there is only the HE body
+ %$bodies = map { $_->id => $_ } grep { $_->name eq 'Highways England' } values %$bodies;
+}
+
+sub report_new_is_on_he_road {
+ my ( $self ) = @_;
+
+ my ($x, $y) = (
+ $self->{c}->stash->{longitude},
+ $self->{c}->stash->{latitude},
+ );
+
+ my $cfg = {
+ url => "https://tilma.mysociety.org/mapserver/highways",
+ srsname => "urn:ogc:def:crs:EPSG::4326",
+ typename => "Highways",
+ filter => "<Filter><DWithin><PropertyName>geom</PropertyName><gml:Point><gml:coordinates>$x,$y</gml:coordinates></gml:Point><Distance units='m'>15</Distance></DWithin></Filter>",
+ };
+
+ my $ukc = FixMyStreet::Cobrand::UKCouncils->new;
+ my $features = $ukc->_fetch_features($cfg, $x, $y);
+ return scalar @$features ? 1 : 0;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Hounslow.pm b/perllib/FixMyStreet/Cobrand/Hounslow.pm
new file mode 100644
index 000000000..2fc949546
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/Hounslow.pm
@@ -0,0 +1,174 @@
+package FixMyStreet::Cobrand::Hounslow;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use Moo;
+with 'FixMyStreet::Roles::ConfirmOpen311';
+with 'FixMyStreet::Roles::ConfirmValidation';
+
+sub council_area_id { 2483 }
+sub council_area { 'Hounslow' }
+sub council_name { 'Hounslow Highways' }
+sub council_url { 'hounslow' }
+
+sub map_type { 'Hounslow' }
+
+sub enter_postcode_text {
+ my ($self) = @_;
+ return "Enter a Hounslow street name and area, or postcode";
+}
+
+sub admin_user_domain { ('hounslowhighways.org', 'hounslow.gov.uk') }
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ my $town = "Hounslow";
+
+ # Some specific Hounslow roads produce lots of geocoder results
+ # for the same road; this picks just one.
+ ( $string, $town ) = ( "TW3 4HR", "" ) if $string =~ /lampton\s+road/i;
+ ( $string, $town ) = ( "TW3 4AJ", "" ) if $string =~ /kingsley\s+road/i;
+ ( $string, $town ) = ( "TW3 1YQ", "" ) if $string =~ /stanborough\s+road/i;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ string => $string,
+ centre => '51.468495,-0.366134',
+ town => $town,
+ bounds => [ 51.420739, -0.461502, 51.502850, -0.243443 ],
+ };
+}
+
+sub get_geocoder {
+ return 'OSM'; # default of Bing gives poor results, let's try overriding.
+}
+
+sub on_map_default_status { 'open' }
+
+sub pin_colour {
+ my ( $self, $p, $context ) = @_;
+ return 'green' if $p->is_fixed || $p->is_closed;
+ return 'yellow';
+}
+
+sub send_questionnaires { 0 }
+
+sub categories_restriction {
+ my ($self, $rs) = @_;
+ # Categories covering the Hounslow area have a mixture of Open311 and Email
+ # send methods. Hounslow only want Open311 categories to be visible on their
+ # cobrand, not the email categories from FMS.com. We've set up the
+ # Email categories with a devolved send_method, so can identify Open311
+ # categories as those which have a blank send_method.
+ return $rs->search({
+ 'me.send_method' => undef,
+ 'body.name' => [ 'Hounslow Borough Council', 'Highways England' ],
+ });
+}
+
+sub report_sent_confirmation_email { 'external_id' }
+
+# Used to change the "Sent to" line on report pages
+sub link_to_council_cobrand { "Hounslow Highways" }
+
+# The "all reports" link will default to using council_name, which
+# in our case doesn't correspond to a body and so causes an infinite redirect.
+# Instead, force the borough council name to be used.
+sub all_reports_single_body { { name => "Hounslow Borough Council" } }
+
+sub open311_post_send {
+ my ($self, $row, $h) = @_;
+
+ # Stop the email being sent for each Open311 failure; only the once.
+ return if $row->get_extra_metadata('hounslow_email_sent');
+
+ my $e = join( '@', 'enquiries', $self->council_url . 'highways.org' );
+ my $sender = FixMyStreet::SendReport::Email->new( to => [ [ $e, 'Hounslow Highways' ] ] );
+ if (!$sender->send($row, $h)) {
+ $row->set_extra_metadata('hounslow_email_sent', 1);
+ }
+}
+
+around 'open311_config' => sub {
+ my ($orig, $self, $row, $h, $params) = @_;
+
+ $params->{upload_files} = 1;
+ $self->$orig($row, $h, $params);
+};
+
+sub open311_munge_update_params {
+ my ($self, $params, $comment, $body) = @_;
+
+ # Hounslow want to make it clear in Confirm when an update is left by
+ # someone who's not the original reporter.
+ unless ($comment->user eq $comment->problem->user) {
+ $params->{description} = "[This comment was not left by the original problem reporter] " . $params->{description};
+ }
+}
+
+sub open311_skip_report_fetch {
+ my ($self, $problem) = @_;
+
+ return 1 if $problem->non_public;
+}
+
+# Make sure fetched report description isn't shown.
+sub filter_report_description { "" }
+
+sub setup_general_enquiries_stash {
+ my $self = shift;
+
+ my @bodies = $self->{c}->model('DB::Body')->active->for_areas(( $self->council_area_id ))->all;
+ my %bodies = map { $_->id => $_ } @bodies;
+ my @contacts #
+ = $self->{c} #
+ ->model('DB::Contact') #
+ ->active
+ ->search(
+ {
+ 'me.body_id' => [ keys %bodies ]
+ },
+ {
+ prefetch => 'body',
+ order_by => 'me.category',
+ }
+ )->all;
+ @contacts = grep {
+ my $group = $_->get_extra_metadata('group') || '';
+ $group eq 'Other' || $group eq 'General Enquiries';
+ } @contacts;
+ $self->{c}->stash->{bodies} = \%bodies;
+ $self->{c}->stash->{bodies_to_list} = \%bodies;
+ $self->{c}->stash->{contacts} = \@contacts;
+ $self->{c}->stash->{missing_details_bodies} = [];
+ $self->{c}->stash->{missing_details_body_names} = [];
+
+ $self->{c}->set_param('title', "General Enquiry");
+ # Can't use (0, 0) for lat lon so default to the rough location
+ # of Hounslow Highways HQ.
+ $self->{c}->stash->{latitude} = 51.469;
+ $self->{c}->stash->{longitude} = -0.35;
+
+ return 1;
+}
+
+sub abuse_reports_only { 1 }
+
+sub lookup_site_code_config { {
+ buffer => 50, # metres
+ url => "https://tilma.mysociety.org/mapserver/hounslow",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "streets",
+ property => "SITE_CODE",
+ accept_feature => sub { 1 }
+} }
+
+# Hounslow don't want any reports made before their go-live date visible on
+# their cobrand at all.
+sub cut_off_date { '2019-05-06' }
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/IsleOfWight.pm b/perllib/FixMyStreet/Cobrand/IsleOfWight.pm
new file mode 100644
index 000000000..db0a20b9c
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/IsleOfWight.pm
@@ -0,0 +1,233 @@
+package FixMyStreet::Cobrand::IsleOfWight;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use Moo;
+with 'FixMyStreet::Roles::ConfirmOpen311';
+
+sub council_area_id { 2636 }
+sub council_area { 'Isle of Wight' }
+sub council_name { 'Island Roads' }
+sub council_url { 'isleofwight' }
+sub all_reports_single_body { { name => "Isle of Wight Council" } }
+sub link_to_council_cobrand { "Island Roads" }
+
+sub enter_postcode_text {
+ my ($self) = @_;
+ return 'Enter an ' . $self->council_area . ' postcode, or street name and area';
+}
+
+sub admin_user_domain { ('islandroads.com') }
+
+sub on_map_default_status { 'open' }
+
+sub send_questionnaires { 0 }
+
+sub report_sent_confirmation_email { 'external_id' }
+
+sub map_type { 'IsleOfWight' }
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ centre => '50.675761,-1.296571',
+ bounds => [ 50.574653, -1.591732, 50.767567, -1.062957 ],
+ };
+}
+
+sub updates_disallowed {
+ my ($self, $problem) = @_;
+
+ my $c = $self->{c};
+ return 0 if $c->user_exists && $c->user->id eq $problem->user->id;
+ return 1;
+}
+
+# Island Roads don't want any reports made before their go-live date visible on
+# their cobrand at all.
+sub cut_off_date { '2019-09-30' }
+
+sub get_geocoder { 'OSM' }
+
+sub lookup_site_code_config { {
+ buffer => 50, # metres
+ url => "https://tilma.mysociety.org/mapserver/iow",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "streets",
+ property => "SITE_CODE",
+ accept_feature => sub { 1 }
+} }
+
+sub open311_pre_send {
+ my ($self, $row, $open311) = @_;
+
+ return unless $row->extra;
+ my $extra = $row->get_extra_fields;
+ if (@$extra) {
+ @$extra = grep { $_->{name} ne 'urgent' } @$extra;
+ $row->set_extra_fields(@$extra);
+ }
+}
+
+# Make sure fetched report description isn't shown.
+sub filter_report_description { "" }
+
+sub open311_munge_update_params {
+ my ($self, $params, $comment, $body) = @_;
+
+ if ($comment->mark_fixed) {
+ $params->{description} = "[The customer indicated that this issue had been fixed]\n\n" . $params->{description};
+ }
+
+ if ( $comment->get_extra_metadata('triage_report') ) {
+ $params->{description} = "Triaged by " . $comment->user->name . ' (' . $comment->user->email . "). " . $params->{description};
+ }
+
+ $params->{description} = "FMS-Update: " . $params->{description};
+}
+
+# this handles making sure the user sees the right categories on the new report page
+sub munge_reports_category_list {
+ my ($self, $categories) = @_;
+
+ my $user = $self->{c}->user;
+ my %bodies = map { $_->body->name => $_->body } @$categories;
+ my $b = $bodies{'Isle of Wight Council'};
+
+ if ( $user && ( $user->is_superuser || $user->belongs_to_body( $b->id ) ) ) {
+ @$categories = grep { !$_->send_method || $_->send_method ne 'Triage' } @$categories;
+ return @$categories;
+ }
+
+ @$categories = grep { $_->send_method && $_->send_method eq 'Triage' } @$categories;
+ return @$categories;
+}
+
+sub munge_report_new_contacts {
+ my ($self, $contacts) = @_;
+
+ my $user = $self->{c}->user;
+ my %bodies = map { $_->body->name => $_->body } @$contacts;
+ my $b = $bodies{'Isle of Wight Council'};
+
+ if ( $user && ( $user->is_superuser || $user->belongs_to_body( $b->id ) ) ) {
+ @$contacts = grep { !$_->send_method || $_->send_method ne 'Triage' } @$contacts;
+ return;
+ }
+
+ @$contacts = grep { $_->send_method && $_->send_method eq 'Triage' } @$contacts;
+}
+
+sub munge_around_category_where {
+ my ($self, $where) = @_;
+
+ my $user = $self->{c}->user;
+ my $b = $self->{c}->model('DB::Body')->for_areas( $self->council_area_id )->first;
+ if ( $user && ( $user->is_superuser || $user->belongs_to_body( $b->id ) ) ) {
+ $where->{send_method} = [ { '!=' => 'Triage' }, undef ];
+ return $where;
+ }
+
+ $where->{'send_method'} = 'Triage';
+ return $where;
+}
+
+sub munge_load_and_group_problems {
+ my ($self, $where, $filter) = @_;
+
+ return unless $where->{category};
+
+ $where->{category} = $self->_expand_triage_cat_list($where->{category});
+}
+
+sub munge_around_filter_category_list {
+ my $self = shift;
+
+ my $c = $self->{c};
+ return unless $c->stash->{filter_category};
+
+ my $cat_names = $self->_expand_triage_cat_list([ keys %{$c->stash->{filter_category}} ]);
+ $c->stash->{filter_category} = { map { $_ => 1 } @$cat_names };
+}
+
+sub _expand_triage_cat_list {
+ my ($self, $categories) = @_;
+ my $b = $self->{c}->model('DB::Body')->for_areas( $self->council_area_id )->first;
+ return $self->expand_triage_cat_list($categories, $b);
+}
+
+# this assumes that each Triage category has the same name as a group
+# and uses this to generate a list of categories that a triage category
+# could be triaged to
+sub expand_triage_cat_list {
+ my ($self, $categories, $b) = @_;
+
+ my $all_cats = $self->{c}->model('DB::Contact')->not_deleted->search(
+ {
+ body_id => $b->id,
+ send_method => [{ '!=', 'Triage'}, undef]
+ }
+ );
+
+ my %group_to_category;
+ while ( my $cat = $all_cats->next ) {
+ next unless $cat->get_extra_metadata('group');
+ my $groups = $cat->get_extra_metadata('group');
+ $groups = ref $groups eq 'ARRAY' ? $groups : [ $groups ];
+ for my $group ( @$groups ) {
+ $group_to_category{$group} //= [];
+ push @{ $group_to_category{$group} }, $cat->category;
+ }
+ }
+
+ my $cats = $self->{c}->model('DB::Contact')->not_deleted->search(
+ {
+ body_id => $b->id,
+ category => $categories
+ }
+ );
+
+ my @cat_names;
+ while ( my $cat = $cats->next ) {
+ if ( $cat->send_method && $cat->send_method eq 'Triage' ) {
+ # include the category itself
+ push @cat_names, $cat->category;
+ push @cat_names, @{ $group_to_category{$cat->category} } if $group_to_category{$cat->category};
+ } else {
+ push @cat_names, $cat->category;
+ }
+ }
+
+ return \@cat_names;
+}
+
+sub open311_get_update_munging {
+ my ($self, $comment) = @_;
+
+ # If we've received an update via Open311 that's closed
+ # or fixed the report, also close it to updates.
+ $comment->problem->set_extra_metadata(closed_updates => 1)
+ if !$comment->problem->is_open;
+}
+
+sub admin_pages {
+ my $self = shift;
+ my $pages = $self->next::method();
+ $pages->{triage} = [ undef, undef ];
+ return $pages;
+}
+
+sub available_permissions {
+ my $self = shift;
+
+ my $perms = $self->next::method();
+ $perms->{Problems}->{triage} = "Triage reports";
+
+ return $perms;
+}
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Lincolnshire.pm b/perllib/FixMyStreet/Cobrand/Lincolnshire.pm
index 8d8ba3268..ee40bb173 100644
--- a/perllib/FixMyStreet/Cobrand/Lincolnshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Lincolnshire.pm
@@ -1,5 +1,5 @@
package FixMyStreet::Cobrand::Lincolnshire;
-use parent 'FixMyStreet::Cobrand::UKCouncils';
+use parent 'FixMyStreet::Cobrand::Whitelabel';
use strict;
use warnings;
@@ -10,6 +10,7 @@ use Try::Tiny;
use JSON::MaybeXS;
use Moo;
+with 'FixMyStreet::Roles::ConfirmOpen311';
with 'FixMyStreet::Roles::ConfirmValidation';
sub council_area_id { return 2232; }
@@ -18,7 +19,6 @@ sub council_name { return 'Lincolnshire County Council'; }
sub council_url { return 'lincolnshire'; }
sub is_two_tier { 1 }
-sub enable_category_groups { 1 }
sub send_questionnaires { 0 }
sub report_sent_confirmation_email { 'external_id' }
@@ -29,23 +29,6 @@ sub enter_postcode_text {
return 'Enter a Lincolnshire postcode, street name and area, or check an existing report number';
}
-
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.lincolnshire.gov.uk';
-}
-
-sub contact_email {
- my $self = shift;
- return join( '@', 'confirm_support', 'lincolnshire.gov.uk' );
-}
-
-
-sub example_places {
- return ( 'LN1 1YL', 'Orchard Street, Lincoln' );
-}
-
sub disambiguate_location {
my $self = shift;
my $string = shift;
@@ -59,33 +42,6 @@ sub disambiguate_location {
}
-sub open311_config {
- my ($self, $row, $h, $params) = @_;
-
- my $extra = $row->get_extra_fields;
- push @$extra,
- { name => 'report_url',
- value => $h->{url} },
- { name => 'title',
- value => $row->title },
- { name => 'description',
- value => $row->detail };
-
- # Reports made via FMS.com or the app probably won't have a site code
- # value because we don't display the adopted highways layer on those
- # frontends. Instead we'll look up the closest asset from the WFS
- # service at the point we're sending the report over Open311.
- if (!$row->get_extra_field_value('site_code')) {
- if (my $site_code = $self->lookup_site_code($row)) {
- push @$extra,
- { name => 'site_code',
- value => $site_code };
- }
- }
-
- $row->set_extra_fields(@$extra);
-}
-
sub lookup_site_code_config { {
buffer => 200, # metres
url => "https://tilma.mysociety.org/mapserver/lincs",
@@ -101,7 +57,7 @@ sub categories_restriction {
# Lincolnshire is a two-tier council, but don't want to display
# all district-level categories on their cobrand - just a couple.
return $rs->search( { -or => [
- 'body.name' => "Lincolnshire County Council",
+ 'body.name' => [ "Lincolnshire County Council", 'Highways England' ],
# District categories:
'me.category' => { -in => [
@@ -111,8 +67,6 @@ sub categories_restriction {
] } );
}
-sub map_type { 'Lincolnshire' }
-
sub pin_colour {
my ( $self, $p, $context ) = @_;
my $ext_status = $p->get_extra_metadata('external_status_code');
diff --git a/perllib/FixMyStreet/Cobrand/Northamptonshire.pm b/perllib/FixMyStreet/Cobrand/Northamptonshire.pm
index 683dc059c..3e32b0856 100644
--- a/perllib/FixMyStreet/Cobrand/Northamptonshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Northamptonshire.pm
@@ -12,16 +12,8 @@ sub council_area { 'Northamptonshire' }
sub council_name { 'Northamptonshire County Council' }
sub council_url { 'northamptonshire' }
-sub example_places { ( 'NN1 1NS', "Bridge Street" ) }
-
sub enter_postcode_text { 'Enter a Northamptonshire postcode, street name and area, or check an existing report number' }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.northamptonshire.gov.uk';
-}
-
sub disambiguate_location {
my $self = shift;
my $string = shift;
@@ -35,7 +27,7 @@ sub disambiguate_location {
sub categories_restriction {
my ($self, $rs) = @_;
- return $rs->search( [ { 'body.name' => 'Northamptonshire County Council' } ], { join => { body => 'body_areas' } });
+ return $rs->search( { 'body.name' => [ 'Northamptonshire County Council', 'Highways England' ] } );
}
sub send_questionnaires { 0 }
@@ -44,6 +36,38 @@ sub on_map_default_status { 'open' }
sub report_sent_confirmation_email { 'id' }
+sub admin_user_domain { 'northamptonshire.gov.uk' }
+
+has body_obj => (
+ is => 'lazy',
+ default => sub {
+ FixMyStreet::DB->resultset('Body')->find({ name => 'Northamptonshire County Council' });
+ },
+);
+
+sub updates_disallowed {
+ my $self = shift;
+ my ($problem) = @_;
+
+ # Only open reports
+ return 1 if $problem->is_fixed || $problem->is_closed;
+ # Not on reports made by the body user
+ return 1 if $problem->user_id == $self->body_obj->comment_user_id;
+
+ return $self->next::method(@_);
+}
+
+sub is_defect {
+ my ($self, $p) = @_;
+ return $p->user_id == $self->body_obj->comment_user_id;
+}
+
+sub pin_colour {
+ my ($self, $p, $context) = @_;
+ return 'blue' if $self->is_defect($p);
+ return $self->SUPER::pin_colour($p, $context);
+}
+
sub problems_on_map_restriction {
my ($self, $rs) = @_;
# Northamptonshire don't want to show district/borough reports
@@ -51,32 +75,26 @@ sub problems_on_map_restriction {
return $self->problems_restriction($rs);
}
-sub contact_email {
- my $self = shift;
- return join( '@', 'highways', $self->council_url . '.gov.uk' );
-}
-
sub privacy_policy_url {
'https://www3.northamptonshire.gov.uk/councilservices/council-and-democracy/transparency/information-policies/privacy-notice/place/Pages/street-doctor.aspx'
}
-sub enable_category_groups { 1 }
-
sub is_two_tier { 1 }
sub get_geocoder { 'OSM' }
-sub map_type { 'OSM' }
+sub map_type { 'Northamptonshire' }
sub open311_config {
my ($self, $row, $h, $params) = @_;
- my $extra = $row->get_extra_fields;
+ $params->{multi_photos} = 1;
+}
- # remove the emergency category which is informational only
- @$extra = grep { $_->{name} ne 'emergency' } @$extra;
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
- push @$extra,
+ return ([
{ name => 'report_url',
value => $h->{url} },
{ name => 'title',
@@ -84,15 +102,34 @@ sub open311_config {
{ name => 'description',
value => $row->detail },
{ name => 'category',
- value => $row->category };
+ value => $row->category },
+ ], [
+ 'emergency'
+ ]);
+}
- $row->set_extra_fields(@$extra);
+sub open311_get_update_munging {
+ my ($self, $comment) = @_;
- $params->{multi_photos} = 1;
+ # If we've received an update via Open311, let us always take its state change
+ my $state = $comment->problem_state;
+ my $p = $comment->problem;
+ if ($state && $p->state ne $state && $p->is_visible) {
+ $p->state($state);
+ }
}
-# sending updates not part of initial phase
-sub should_skip_sending_update { 1; }
+sub should_skip_sending_update {
+ my ($self, $comment) = @_;
+
+ my $p = $comment->problem;
+ my %body_users = map { $_->comment_user_id => 1 } values %{ $p->bodies };
+ if ( $body_users{ $p->user->id } ) {
+ return 1;
+ }
+
+ return 0;
+}
sub report_validation {
my ($self, $report, $errors) = @_;
@@ -102,4 +139,13 @@ sub report_validation {
}
}
+sub staff_ignore_form_disable_form {
+ my $self = shift;
+
+ my $c = $self->{c};
+
+ return $c->user_exists
+ && $c->user->belongs_to_body( $self->body->id );
+}
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm
index 08482a0b3..8ce12a81b 100644
--- a/perllib/FixMyStreet/Cobrand/Oxfordshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Oxfordshire.pm
@@ -37,12 +37,6 @@ sub is_council_with_case_management {
return FixMyStreet->config('STAGING_SITE');
}
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'https://fixmystreet.oxfordshire.gov.uk';
-}
-
sub enter_postcode_text {
my ($self) = @_;
return 'Enter an Oxfordshire postcode, or street name and area';
@@ -60,10 +54,6 @@ sub disambiguate_location {
};
}
-sub example_places {
- return ( 'OX20 1SZ', 'Park St, Woodstock' );
-}
-
# don't send questionnaires to people who used the OCC cobrand to report their problem
sub send_questionnaires { return 0; }
@@ -89,54 +79,6 @@ sub lookup_by_ref {
return 0;
}
-=head2 problem_response_days
-
-Returns the number of working days that are expected to elapse
-between the problem being reported and it being responded to by
-the council/body.
-If the value 'emergency' is returned, a different template block
-is triggered that has custom wording.
-
-=cut
-
-sub problem_response_days {
- my $self = shift;
- my $p = shift;
-
- return 'emergency' if $p->category eq 'Street lighting';
-
- # Temporary, see https://github.com/mysociety/fixmystreetforcouncils/issues/291
- return 0;
-
- return 10 if $p->category eq 'Bridges';
- return 10 if $p->category eq 'Carriageway Defect'; # phone if urgent
- return 10 if $p->category eq 'Debris/Spillage';
- return 10 if $p->category eq 'Drainage';
- return 10 if $p->category eq 'Fences';
- return 10 if $p->category eq 'Flyposting';
- return 10 if $p->category eq 'Footpaths/ Rights of way (usually not tarmac)';
- return 10 if $p->category eq 'Gully and Catchpits';
- return 10 if $p->category eq 'Ice/Snow'; # phone if urgent
- return 10 if $p->category eq 'Manhole';
- return 10 if $p->category eq 'Mud and Debris'; # phone if urgent
- return 10 if $p->category eq 'Oil Spillage'; # phone if urgent
- return 10 if $p->category eq 'Pavements';
- return 10 if $p->category eq 'Pothole'; # phone if urgent
- return 10 if $p->category eq 'Property Damage';
- return 10 if $p->category eq 'Public rights of way';
- return 10 if $p->category eq 'Road Marking';
- return 10 if $p->category eq 'Road traffic signs';
- return 10 if $p->category eq 'Roads/highways';
- return 10 if $p->category eq 'Skips and scaffolding';
- return 10 if $p->category eq 'Traffic lights'; # phone if urgent
- return 10 if $p->category eq 'Traffic';
- return 10 if $p->category eq 'Trees';
- return 10 if $p->category eq 'Utilities';
- return 10 if $p->category eq 'Vegetation';
-
- return 0;
-}
-
sub reports_ordering {
return 'created-desc';
}
@@ -176,17 +118,19 @@ sub state_groups_inspect {
sub open311_config {
my ($self, $row, $h, $params) = @_;
- my $extra = $row->get_extra_fields;
- push @$extra, { name => 'external_id', value => $row->id };
- push @$extra, { name => 'northing', value => $h->{northing} };
- push @$extra, { name => 'easting', value => $h->{easting} };
+ $params->{multi_photos} = 1;
+ $params->{extended_description} = 'oxfordshire';
+}
- if ($h->{closest_address}) {
- push @$extra, { name => 'closest_address', value => "$h->{closest_address}" }
- }
- $row->set_extra_fields( @$extra );
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
- $params->{extended_description} = 'oxfordshire';
+ return [
+ { name => 'external_id', value => $row->id },
+ { name => 'northing', value => $h->{northing} },
+ { name => 'easting', value => $h->{easting} },
+ $h->{closest_address} ? { name => 'closest_address', value => "$h->{closest_address}" } : (),
+ ];
}
sub open311_config_updates {
@@ -198,17 +142,17 @@ sub should_skip_sending_update {
my ($self, $update ) = @_;
# Oxfordshire stores the external id of the problem as a customer reference
- # in metadata
- return 1 if !$update->problem->get_extra_metadata('customer_reference');
+ # in metadata, it arrives in a fetched update (but give up if it never does,
+ # or the update is for an old pre-ref report)
+ my $customer_ref = $update->problem->get_extra_metadata('customer_reference');
+ my $diff = time() - $update->confirmed->epoch;
+ return 1 if !$customer_ref && $diff > 60*60*24;
+ return 'WAIT' if !$customer_ref;
+ return 0;
}
sub on_map_default_status { return 'open'; }
-sub contact_email {
- my $self = shift;
- return join( '@', 'highway.enquiries', 'oxfordshire.gov.uk' );
-}
-
sub admin_user_domain { 'oxfordshire.gov.uk' }
sub traffic_management_options {
@@ -226,11 +170,6 @@ sub admin_pages {
my $pages = $self->next::method();
- # Oxfordshire have a custom admin page for downloading reports in an Exor-
- # friendly format which anyone with report_instruct permission can use.
- if ( $user->has_body_permission_to('report_instruct') ) {
- $pages->{exordefects} = [ ('Download Exor RDI'), 10 ];
- }
if ( $user->has_body_permission_to('defect_type_edit') ) {
$pages->{defecttypes} = [ ('Defect Types'), 11 ];
$pages->{defecttype_edit} = [ undef, undef ];
@@ -239,22 +178,6 @@ sub admin_pages {
return $pages;
}
-sub defect_types {
- {
- SFP2 => "SFP2: sweep and fill <1m2",
- POT2 => "POT2",
- };
-}
-
-sub exor_rdi_link_id { 1989169 }
-sub exor_rdi_link_length { 50 }
-
-sub reputation_increment_states {
- return {
- 'action scheduled' => 1,
- };
-}
-
sub user_extra_fields {
return [ 'initials' ];
}
@@ -276,14 +199,30 @@ sub available_permissions {
my $perms = $self->next::method();
$perms->{Bodies}->{defect_type_edit} = "Add/edit defect types";
- delete $perms->{Problems}->{report_edit};
- delete $perms->{Problems}->{report_edit_category};
- delete $perms->{Problems}->{report_edit_priority};
- delete $perms->{Problems}->{report_inspect};
- delete $perms->{Problems}->{report_instruct};
- delete $perms->{Problems}->{planned_reports};
-
return $perms;
}
+sub dashboard_export_problems_add_columns {
+ my $self = shift;
+ my $c = $self->{c};
+
+ push @{$c->stash->{csv}->{headers}}, "HIAMS/Exor Ref";
+ push @{$c->stash->{csv}->{columns}}, "external_ref";
+
+ $c->stash->{csv}->{extra_data} = sub {
+ my $report = shift;
+ # Try and get a HIAMS reference first of all
+ my $ref = $report->get_extra_metadata('customer_reference');
+ unless ($ref) {
+ # No HIAMS ref which means it's either an older Exor report
+ # or a HIAMS report which hasn't had its reference set yet.
+ # We detect the latter case by the id and external_id being the same.
+ $ref = $report->external_id if $report->id ne ( $report->external_id || '' );
+ }
+ return {
+ external_ref => ( $ref || '' ),
+ };
+ };
+}
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/Peterborough.pm b/perllib/FixMyStreet/Cobrand/Peterborough.pm
new file mode 100644
index 000000000..0ddaeacb6
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/Peterborough.pm
@@ -0,0 +1,92 @@
+package FixMyStreet::Cobrand::Peterborough;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use Moo;
+with 'FixMyStreet::Roles::ConfirmOpen311';
+with 'FixMyStreet::Roles::ConfirmValidation';
+
+sub council_area_id { 2566 }
+sub council_area { 'Peterborough' }
+sub council_name { 'Peterborough City Council' }
+sub council_url { 'peterborough' }
+sub map_type { 'MasterMap' }
+
+sub send_questionnaires { 0 }
+
+sub max_title_length { 50 }
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ centre => '52.6085234396978,-0.253091266573947',
+ bounds => [ 52.5060949603654, -0.497663559599628, 52.6752139533306, -0.0127696975457487 ],
+ };
+}
+
+sub get_geocoder { 'OSM' }
+
+sub geocoder_munge_results {
+ my ($self, $result) = @_;
+ $result->{display_name} = '' unless $result->{display_name} =~ /City of Peterborough/;
+ $result->{display_name} =~ s/, UK$//;
+ $result->{display_name} =~ s/, City of Peterborough, East of England, England//;
+}
+
+sub admin_user_domain { "peterborough.gov.uk" }
+
+around open311_extra_data => sub {
+ my ($orig, $self, $row, $h, $extra) = @_;
+
+ my $open311_only = $self->$orig($row, $h, $extra);
+ foreach (@$open311_only) {
+ if ($_->{name} eq 'description') {
+ my ($ref) = grep { $_->{name} =~ /pcc-Skanska-csc-ref/i } @{$row->get_extra_fields};
+ $_->{value} .= "\n\nSkanska CSC ref: $ref->{value}" if $ref;
+ }
+ }
+ return $open311_only;
+};
+
+# remove categories which are informational only
+sub open311_pre_send {
+ my ($self, $row, $open311) = @_;
+
+ return unless $row->extra;
+ my $extra = $row->get_extra_fields;
+ if (@$extra) {
+ @$extra = grep { $_->{name} !~ /^(PCC-|emergency$|private_land$)/i } @$extra;
+ $row->set_extra_fields(@$extra);
+ }
+}
+
+sub lookup_site_code_config { {
+ buffer => 50, # metres
+ url => "https://tilma.mysociety.org/mapserver/peterborough",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "highways",
+ property => "Usrn",
+ accept_feature => sub { 1 },
+ accept_types => { Polygon => 1 },
+} }
+
+sub open311_munge_update_params {
+ my ($self, $params, $comment, $body) = @_;
+
+ # Peterborough want to make it clear in Confirm when an update has come
+ # from FMS.
+ $params->{description} = "[Customer FMS update] " . $params->{description};
+
+ # Send the FMS problem ID with the update.
+ $params->{service_request_id_ext} = $comment->problem->id;
+
+ my $contact = $comment->problem->category_row;
+ $params->{service_code} = $contact->email;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Rutland.pm b/perllib/FixMyStreet/Cobrand/Rutland.pm
index af635ac59..63a20d893 100644
--- a/perllib/FixMyStreet/Cobrand/Rutland.pm
+++ b/perllib/FixMyStreet/Cobrand/Rutland.pm
@@ -12,6 +12,10 @@ sub council_url { return 'rutland'; }
sub report_validation {
my ($self, $report, $errors) = @_;
+ if ( length( $report->title ) > 254 ) {
+ $errors->{title} = sprintf( _('Summaries are limited to %s characters in length. Please shorten your summary'), 254 );
+ }
+
if ( length( $report->name ) > 40 ) {
$errors->{name} = sprintf( _('Names are limited to %d characters in length.'), 40 );
}
@@ -22,21 +26,18 @@ sub report_validation {
sub open311_config {
my ($self, $row, $h, $params) = @_;
- my $extra = $row->get_extra_fields;
- push @$extra, { name => 'external_id', value => $row->id };
- push @$extra, { name => 'title', value => $row->title };
- push @$extra, { name => 'description', value => $row->detail };
-
- if ($h->{closest_address}) {
- push @$extra, { name => 'closest_address', value => "$h->{closest_address}" }
- }
- $row->set_extra_fields( @$extra );
-
$params->{multi_photos} = 1;
}
-sub example_places {
- return ( 'LE15 6HP', 'High Street', 'Oakham' );
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
+
+ return [
+ { name => 'external_id', value => $row->id },
+ { name => 'title', value => $row->title },
+ { name => 'description', value => $row->detail },
+ $h->{closest_address} ? { name => 'closest_address', value => "$h->{closest_address}" } : (),
+ ];
}
sub disambiguate_location {
@@ -48,12 +49,6 @@ sub disambiguate_location {
};
}
-sub pin_colour {
- my ( $self, $p, $context ) = @_;
- return 'green' if $p->is_fixed || $p->is_closed;
- return 'yellow';
-}
-
sub send_questionnaires {
return 0;
}
@@ -62,4 +57,6 @@ sub ask_ever_reported {
return 0;
}
+sub on_map_default_status { 'open' }
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/Stevenage.pm b/perllib/FixMyStreet/Cobrand/Stevenage.pm
index 28734b14b..9b0e91016 100644
--- a/perllib/FixMyStreet/Cobrand/Stevenage.pm
+++ b/perllib/FixMyStreet/Cobrand/Stevenage.pm
@@ -10,12 +10,6 @@ sub council_name { return 'Stevenage Council'; }
sub council_url { return 'stevenage'; }
sub is_two_tier { return 1; }
-sub base_url {
- my $self = shift;
- return $self->next::method() if FixMyStreet->config('STAGING_SITE');
- return 'http://fixmystreet.stevenage.gov.uk';
-}
-
sub disambiguate_location {
my $self = shift;
return {
@@ -27,18 +21,9 @@ sub disambiguate_location {
};
}
-sub example_places {
- return [ 'SG1 1HN', 'Lyton Way' ];
-}
-
sub default_map_zoom { return 3; }
sub users_can_hide { return 1; }
-sub contact_email {
- my $self = shift;
- return join( '@', 'csc', 'stevenage.gov.uk' );
-}
-
1;
diff --git a/perllib/FixMyStreet/Cobrand/TfL.pm b/perllib/FixMyStreet/Cobrand/TfL.pm
new file mode 100644
index 000000000..b98ad1d8b
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/TfL.pm
@@ -0,0 +1,543 @@
+package FixMyStreet::Cobrand::TfL;
+use parent 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use Moo;
+with 'FixMyStreet::Roles::BoroughEmails';
+
+use POSIX qw(strcoll);
+
+use FixMyStreet::MapIt;
+use mySociety::ArrayUtils;
+use Utils;
+
+sub london_boroughs { (
+ 2511, 2489, 2494, 2488, 2482, 2505, 2512, 2481, 2484, 2495,
+ 2493, 2508, 2502, 2509, 2487, 2485, 2486, 2483, 2507, 2503,
+ 2480, 2490, 2492, 2500, 2510, 2497, 2499, 2491, 2498, 2506,
+ 2496, 2501, 2504,
+) }
+
+# Surrounding areas for bus stops which can be outside London
+sub surrounding_london { (
+ # Clockwise from top left: South Bucks, Three Rivers, Watford, Hertsmere,
+ # Welwyn Hatfield, Broxbourne, Epping Forest, Brentwood, Thurrock,
+ # Dartford, Sevenoaks, Tandridge, Reigate and Banstead, Epsom and Ewell,
+ # Mole Valley, Elmbridge, Spelthorne, Slough
+ 2256, 2338, 2346, 2339, 2344, 2340, 2311, 2309, 2615,
+ 2358, 2350, 2448, 2453, 2457, 2454, 2455, 2456, 2606,
+) }
+
+sub council_area_id { [ $_[0]->london_boroughs, $_[0]->surrounding_london ] }
+
+sub council_area { return 'TfL'; }
+sub council_name { return 'TfL'; }
+sub council_url { return 'tfl'; }
+sub area_types { [ 'LBO', 'UTA', 'DIS' ] }
+sub is_council { 0 }
+
+sub borough_for_report {
+ my ($self, $problem) = @_;
+
+ # Get relevant area ID from report
+ my %areas = map { $_ => 1 } split ',', $problem->areas;
+ my ($council_match) = grep { $areas{$_} } @{ $self->council_area_id };
+ return unless $council_match;
+
+ # Look up area names if not already fetched
+ my $areas = $self->{c}->stash->{children} ||= $self->fetch_area_children;
+ return $areas->{$council_match}{name};
+}
+
+sub abuse_reports_only { 1 }
+sub send_questionnaires { 0 }
+
+sub disambiguate_location {
+ my $self = shift;
+ my $string = shift;
+
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ town => "London",
+ };
+}
+
+sub get_geocoder { 'OSM' }
+
+sub category_change_force_resend { 1 }
+
+sub do_not_reply_email { shift->feature('do_not_reply_email') }
+
+sub area_check {
+ my ( $self, $params, $context ) = @_;
+
+ my $councils = $params->{all_areas};
+ my $council_match = grep { $councils->{$_} } @{ $self->council_area_id };
+
+ return 1 if $council_match;
+ return ( 0, $self->area_check_error_message($params, $context) );
+}
+
+sub enter_postcode_text {
+ my ($self) = @_;
+ return 'Enter a London postcode, or street name and area, or a reference number of a problem previously reported';
+}
+
+sub privacy_policy_url { 'https://tfl.gov.uk/corporate/privacy-and-cookies/reporting-street-problems' }
+
+sub about_hook {
+ my $self = shift;
+ my $c = $self->{c};
+
+ if ($c->stash->{template} eq 'about/privacy.html') {
+ $c->res->redirect($self->privacy_policy_url);
+ $c->detach;
+ }
+}
+
+sub body {
+ # Overridden because UKCouncils::body excludes TfL
+ FixMyStreet::DB->resultset('Body')->search({ name => 'TfL' })->first;
+}
+
+# These need to be overridden so the method in UKCouncils doesn't create
+# a fixmystreet.com link (because of the false-returning owns_problem call)
+sub relative_url_for_report { "" }
+sub base_url_for_report {
+ my $self = shift;
+ return $self->base_url;
+}
+
+sub categories_restriction {
+ my ($self, $rs) = @_;
+ $rs = $rs->search( { 'body.name' => [ 'TfL', 'Highways England' ] } );
+ return $rs unless $self->{c}->stash->{categories_for_point}; # Admin page
+ return $rs->search( { category => { -not_in => $self->_tfl_no_resend_categories } } );
+}
+
+sub admin_user_domain { 'tfl.gov.uk' }
+
+sub allow_anonymous_reports { 'button' }
+
+sub anonymous_account {
+ my $self = shift;
+ return {
+ email => $self->feature('anonymous_account') . '@' . $self->admin_user_domain,
+ name => 'Anonymous user',
+ };
+}
+
+sub lookup_by_ref_regex {
+ return qr/^\s*((?:FMS\s*)?\d+)\s*$/i;
+}
+
+sub lookup_by_ref {
+ my ($self, $ref) = @_;
+
+ if ( $ref =~ s/^\s*FMS\s*//i ) {
+ return { 'id' => $ref };
+ }
+
+ return 0;
+}
+
+sub report_sent_confirmation_email { 'id' }
+
+sub report_age { '6 weeks' }
+
+# We don't want any reports made before the go-live date visible
+sub cut_off_date { '2019-12-09 12:00' }
+
+sub problems_restriction {
+ my ($self, $rs) = @_;
+ return $rs if FixMyStreet->staging_flag('skip_checks');
+ $rs = $self->next::method($rs);
+ my $table = ref $rs eq 'FixMyStreet::DB::ResultSet::Nearby' ? 'problem' : 'me';
+ $rs = $rs->search({
+ "$table.lastupdate" => { '>=', \"now() - '3 years'::interval" }
+ });
+ return $rs;
+}
+
+sub problems_sql_restriction {
+ my ($self, $item_table) = @_;
+ my $q = $self->next::method($item_table);
+ if ($item_table ne 'comment') {
+ $q .= " AND lastupdate >= now() - '3 years'::interval";
+ }
+ return $q;
+}
+
+sub inactive_reports_filter {
+ my ($self, $time, $rs) = @_;
+ if ($time < 7*12) {
+ $rs = $rs->search({ extra => { like => '%safety_critical,T5:value,T2:no%' } });
+ } else {
+ $rs = $rs->search({ extra => { like => '%safety_critical,T5:value,T3:yes%' } });
+ }
+ return $rs;
+}
+
+sub password_expiry {
+ return if FixMyStreet->test_mode;
+ # uncoverable statement
+ 86400 * 365
+}
+
+sub pin_colour {
+ my ( $self, $p, $context ) = @_;
+ return 'green' if $p->is_closed;
+ return 'green' if $p->is_fixed;
+ return 'red' if $p->state eq 'confirmed';
+ return 'orange'; # all the other `open_states` like "in progress"
+}
+
+sub admin_allow_user {
+ my ( $self, $user ) = @_;
+ return 1 if $user->is_superuser;
+ return undef unless defined $user->from_body;
+ return $user->from_body->name eq 'TfL';
+}
+
+sub around_nearby_filter {
+ my ($self, $params) = @_;
+ # Include all reports in duplicate spotting
+ delete $params->{states};
+}
+
+sub state_groups_inspect {
+ my $rs = FixMyStreet::DB->resultset("State");
+ my @open = grep { $_ !~ /^(planned|action scheduled|for triage)$/ } FixMyStreet::DB::Result::Problem->open_states;
+ my @closed = grep { $_ ne 'closed' } FixMyStreet::DB::Result::Problem->closed_states;
+ [
+ [ $rs->display('confirmed'), \@open ],
+ [ $rs->display('fixed'), [ 'fixed - council' ] ],
+ [ $rs->display('closed'), \@closed ],
+ ]
+}
+
+sub fetch_area_children {
+ my $self = shift;
+
+ # This is for user admin display, in testing can only be London (for MapIt mock)
+ my $ids = FixMyStreet->test_mode ? 'LBO' : $self->council_area_id;
+ my $areas = FixMyStreet::MapIt::call('areas', $ids);
+ foreach (keys %$areas) {
+ $areas->{$_}->{name} =~ s/\s*(Borough|City|District|County) Council$//;
+ }
+ return $areas;
+}
+
+sub available_permissions {
+ my $self = shift;
+
+ my $perms = $self->next::method();
+
+ delete $perms->{Problems}->{report_edit_priority};
+ delete $perms->{Bodies}->{responsepriority_edit};
+
+ return $perms;
+}
+
+sub dashboard_export_problems_add_columns {
+ my $self = shift;
+ my $c = $self->{c};
+
+ my %groups;
+ if ($c->stash->{body}) {
+ %groups = FixMyStreet::DB->resultset('Contact')->search({
+ body_id => $c->stash->{body}->id,
+ })->group_lookup;
+ }
+
+ splice @{$c->stash->{csv}->{headers}}, 5, 0, 'Subcategory';
+ splice @{$c->stash->{csv}->{columns}}, 5, 0, 'subcategory';
+
+ $c->stash->{csv}->{headers} = [
+ map { $_ eq 'Ward' ? 'Borough' : $_ } @{ $c->stash->{csv}->{headers} },
+ "Agent responsible",
+ "Safety critical",
+ "Delivered to",
+ "Closure email at",
+ "Reassigned at",
+ "Reassigned by",
+ ];
+
+ $c->stash->{csv}->{columns} = [
+ @{ $c->stash->{csv}->{columns} },
+ "agent_responsible",
+ "safety_critical",
+ "delivered_to",
+ "closure_email_at",
+ "reassigned_at",
+ "reassigned_by",
+ ];
+
+ if ($c->stash->{category}) {
+ my ($contact) = grep { $_->category eq $c->stash->{category} } @{$c->stash->{contacts}};
+ if ($contact) {
+ foreach (@{$contact->get_metadata_for_storage}) {
+ next if $_->{code} eq 'safety_critical';
+ push @{$c->stash->{csv}->{columns}}, "extra.$_->{code}";
+ push @{$c->stash->{csv}->{headers}}, $_->{description};
+ }
+ }
+ }
+
+ $c->stash->{csv}->{extra_data} = sub {
+ my $report = shift;
+
+ my $agent = $report->shortlisted_user;
+
+ my $change = $report->admin_log_entries->search(
+ { action => 'category_change' },
+ {
+ join => 'user',
+ '+columns' => ['user.name'],
+ rows => 1,
+ order_by => { -desc => 'me.id' }
+ }
+ )->single;
+ my $reassigned_at = $change ? $change->whenedited : '';
+ my $reassigned_by = $change ? $change->user->name : '';
+
+ my $user_name_display = $report->anonymous
+ ? '(anonymous ' . $report->id . ')' : $report->name;
+
+ my $safety_critical = $report->get_extra_field_value('safety_critical') || 'no';
+ my $delivered_to = $report->get_extra_metadata('sent_to') || [];
+ my $closure_email_at = $report->get_extra_metadata('closure_alert_sent_at') || '';
+ $closure_email_at = DateTime->from_epoch(
+ epoch => $closure_email_at, time_zone => FixMyStreet->local_time_zone
+ ) if $closure_email_at;
+ my $fields = {
+ acknowledged => $report->whensent,
+ agent_responsible => $agent ? $agent->name : '',
+ category => $groups{$report->category},
+ subcategory => $report->category,
+ user_name_display => $user_name_display,
+ safety_critical => $safety_critical,
+ delivered_to => join(',', @$delivered_to),
+ closure_email_at => $closure_email_at,
+ reassigned_at => $reassigned_at,
+ reassigned_by => $reassigned_by,
+ };
+ foreach (@{$report->get_extra_fields}) {
+ next if $_->{name} eq 'safety_critical';
+ $fields->{"extra.$_->{name}"} = $_->{value};
+ }
+ return $fields;
+ };
+}
+
+sub must_have_2fa {
+ my ($self, $user) = @_;
+
+ require Net::Subnet;
+ my $ips = $self->feature('internal_ips');
+ my $is_internal_network = Net::Subnet::subnet_matcher(@$ips);
+
+ my $ip = $self->{c}->req->address;
+ return 'skip' if $is_internal_network->($ip);
+ return 1 if $user->is_superuser;
+ return 1 if $user->from_body && $user->from_body->name eq 'TfL';
+ return 0;
+}
+
+sub update_email_shortlisted_user {
+ my ($self, $update) = @_;
+ my $c = $self->{c};
+ my $cobrand = FixMyStreet::Cobrand::TfL->new; # $self may be FMS
+ my $shortlisted_by = $update->problem->shortlisted_user;
+ if ($shortlisted_by && $shortlisted_by->from_body && $shortlisted_by->from_body->name eq 'TfL' && $shortlisted_by->id ne $update->user_id) {
+ $c->send_email('alert-update.txt', {
+ additional_template_paths => [
+ FixMyStreet->path_to( 'templates', 'email', 'tfl' ),
+ FixMyStreet->path_to( 'templates', 'email', 'fixmystreet.com'),
+ ],
+ to => [ [ $shortlisted_by->email, $shortlisted_by->name ] ],
+ report => $update->problem,
+ cobrand => $cobrand,
+ problem_url => $cobrand->base_url . $update->problem->url,
+ data => [ {
+ item_photo => $update->photo,
+ item_text => $update->text,
+ item_name => $update->name,
+ item_anonymous => $update->anonymous,
+ } ],
+ });
+ }
+}
+
+sub report_new_munge_before_insert {
+ my ($self, $report) = @_;
+
+ # Sets the safety critical flag on this report according to category/extra
+ # fields selected.
+
+ my $safety_critical = 0;
+ my $categories = $self->feature('safety_critical_categories');
+ my $category = $categories->{$report->category};
+ if ( ref $category eq 'HASH' ) {
+ # report is safety critical if any of its field values match
+ # the critical values from the config
+ for my $code (keys %$category) {
+ my $value = $report->get_extra_field_value($code);
+ my %critical_values = map { $_ => 1 } @{ $category->{$code} };
+ $safety_critical ||= $critical_values{$value};
+ }
+ } elsif ($category) {
+ # the entire category is safety critical
+ $safety_critical = 1;
+ }
+
+ my $extra = $report->get_extra_fields;
+ @$extra = grep { $_->{name} ne 'safety_critical' } @$extra;
+ push @$extra, { name => 'safety_critical', value => $safety_critical ? 'yes' : 'no' };
+ $report->set_extra_fields(@$extra);
+}
+
+sub report_new_is_on_tlrn {
+ my ( $self ) = @_;
+
+ my ($x, $y) = Utils::convert_latlon_to_en(
+ $self->{c}->stash->{latitude},
+ $self->{c}->stash->{longitude},
+ 'G'
+ );
+
+ my $cfg = {
+ url => "https://tilma.mysociety.org/mapserver/tfl",
+ srsname => "urn:ogc:def:crs:EPSG::27700",
+ typename => "RedRoutes",
+ filter => "<Filter><Contains><PropertyName>geom</PropertyName><gml:Point><gml:coordinates>$x,$y</gml:coordinates></gml:Point></Contains></Filter>",
+ };
+
+ my $features = $self->_fetch_features($cfg, $x, $y);
+ return scalar @$features ? 1 : 0;
+}
+
+sub munge_reports_area_list {
+ my ($self, $areas) = @_;
+ my %london_hash = map { $_ => 1 } $self->london_boroughs;
+ @$areas = grep { $london_hash{$_} } @$areas;
+}
+
+sub munge_report_new_contacts { }
+
+sub munge_report_new_bodies {
+ my ($self, $bodies) = @_;
+
+ # Highways England handling
+ my $c = $self->{c};
+ my $he = FixMyStreet::Cobrand::HighwaysEngland->new({ c => $c });
+ my $on_he_road = $c->stash->{on_he_road} = $he->report_new_is_on_he_road;
+
+ if (!$on_he_road) {
+ %$bodies = map { $_->id => $_ } grep { $_->name ne 'Highways England' } values %$bodies;
+ }
+}
+
+sub munge_surrounding_london {
+ my ($self, $bodies) = @_;
+ # Are we in a London borough?
+ my $all_areas = $self->{c}->stash->{all_areas};
+ my %london_hash = map { $_ => 1 } $self->london_boroughs;
+ if (!grep { $london_hash{$_} } keys %$all_areas) {
+ # Don't send any TfL categories
+ %$bodies = map { $_->id => $_ } grep { $_->name ne 'TfL' } values %$bodies;
+ }
+}
+
+sub munge_red_route_categories {
+ my ($self, $contacts) = @_;
+ if ( $self->report_new_is_on_tlrn ) {
+ # We're on a red route - only send TfL categories (except the disabled
+ # ones that direct the user to borough for street cleaning & flytipping)
+ # and borough street cleaning/flytipping categories.
+ my %cleaning_cats = map { $_ => 1 } @{ $self->_cleaning_categories };
+ my %council_cats = map { $_ => 1 } @{ $self->_tfl_council_categories };
+ @$contacts = grep {
+ ( $_->body->name eq 'TfL' && !$council_cats{$_->category} )
+ || $cleaning_cats{$_->category}
+ || @{ mySociety::ArrayUtils::intersection( $self->_cleaning_groups, $_->groups ) }
+ } @$contacts;
+ } else {
+ # We're not on a red route - send all categories except
+ # TfL red-route-only and the TfL street cleaning & flytipping.
+ my %tlrn_cats = (
+ map { $_ => 1 } @{ $self->_tlrn_categories },
+ map { $_ => 1 } @{ $self->_tfl_council_categories },
+ );
+ @$contacts = grep { !( $_->body->name eq 'TfL' && $tlrn_cats{$_->category } ) } @$contacts;
+ }
+}
+
+# Reports in these categories can only be made on a red route
+sub _tlrn_categories { [
+ "All out - three or more street lights in a row",
+ "Blocked drain",
+ "Damage - general (Trees)",
+ "Dead animal in the carriageway or footway",
+ "Debris in the carriageway",
+ "Fallen Tree",
+ "Flooding",
+ "Graffiti / Flyposting (non-offensive)",
+ "Graffiti / Flyposting (offensive)",
+ "Graffiti / Flyposting on street light (non-offensive)",
+ "Graffiti / Flyposting on street light (offensive)",
+ "Grass Cutting and Hedges",
+ "Hoardings blocking carriageway or footway",
+ "Light on during daylight hours",
+ "Lights out in Pedestrian Subway",
+ "Low hanging branches and general maintenance",
+ "Manhole Cover - Damaged (rocking or noisy)",
+ "Manhole Cover - Missing",
+ "Mobile Crane Operation",
+ "Other (TfL)",
+ "Pavement Defect (uneven surface / cracked paving slab)",
+ "Pothole",
+ "Pothole (minor)",
+ "Roadworks",
+ "Scaffolding blocking carriageway or footway",
+ "Single Light out (street light)",
+ "Standing water",
+ "Street Light - Equipment damaged, pole leaning",
+ "Unstable hoardings",
+ "Unstable scaffolding",
+ "Worn out road markings",
+] }
+
+sub _cleaning_categories { [
+ 'Street cleaning',
+ 'Street Cleaning',
+ 'Street cleaning and litter',
+ 'Accumulated Litter',
+ 'Street Cleaning Enquiry',
+ 'Street Cleansing',
+ 'Flytipping',
+ 'Fly tipping',
+ 'Fly Tipping',
+ 'Fly-tipping',
+ 'Fly-Tipping',
+ 'Fly tipping - Enforcement Request',
+] }
+
+sub _cleaning_groups { [ 'Street cleaning', 'Fly-tipping' ] }
+
+sub _tfl_council_categories { [
+ 'General Litter / Rubbish Collection',
+ 'Flytipping (TfL)',
+] }
+
+sub _tfl_no_resend_categories { [
+ 'Countdown - not working',
+ 'General Litter / Rubbish Collection',
+ 'Flytipping (TfL)',
+ 'Other (TfL)',
+ 'Timings',
+] }
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/UK.pm b/perllib/FixMyStreet/Cobrand/UK.pm
index 1c6ebe29a..a42ff58a6 100644
--- a/perllib/FixMyStreet/Cobrand/UK.pm
+++ b/perllib/FixMyStreet/Cobrand/UK.pm
@@ -6,11 +6,17 @@ use JSON::MaybeXS;
use mySociety::MaPit;
use mySociety::VotingArea;
use Utils;
+use HighwaysEngland;
sub country { return 'GB'; }
sub area_types { [ 'DIS', 'LBO', 'MTD', 'UTA', 'CTY', 'COI', 'LGD' ] }
sub area_types_children { $mySociety::VotingArea::council_child_types }
+sub csp_config {
+ my $self = shift;
+ return $self->feature('content_security_policy');
+}
+
sub enter_postcode_text {
my ( $self ) = @_;
return _("Enter a nearby UK postcode, or street name and area");
@@ -84,19 +90,12 @@ sub geocode_postcode {
latitude => $location->{wgs84_lat},
longitude => $location->{wgs84_lon},
};
+ } elsif (my $junction_location = HighwaysEngland::junction_lookup($s)) {
+ return $junction_location;
}
return {};
}
-sub remove_redundant_areas {
- my $self = shift;
- my $all_areas = shift;
-
- # Norwich is responsible for everything in its areas, not Norfolk
- delete $all_areas->{2233} #
- if $all_areas->{2391};
-}
-
sub short_name {
my $self = shift;
my ($area) = @_;
@@ -107,6 +106,7 @@ sub short_name {
return 'Durham+County' if $name eq 'Durham County Council';
return 'Durham+City' if $name eq 'Durham City Council';
+ $name =~ s/^(Royal|London) Borough of //;
$name =~ s/ (Borough|City|District|County) Council$//;
$name =~ s/ Council$//;
$name =~ s/ & / and /;
@@ -139,6 +139,13 @@ sub find_closest {
sub reports_body_check {
my ( $self, $c, $code ) = @_;
+ # Deal with Bexley and Greenwich name not starting with short name
+ if ($code =~ /bexley|greenwich/i) {
+ my $body = $c->model('DB::Body')->search( { name => { -like => "%$code%" } } )->single;
+ $c->stash->{body} = $body;
+ return $body;
+ }
+
# Manual misspelling redirect
if ($code =~ /^rhondda cynon taff$/i) {
my $url = $c->uri_for( '/reports/Rhondda+Cynon+Taf' );
@@ -354,8 +361,14 @@ cobrand class is returned, otherwise the default FixMyStreet cobrand is used.
sub get_body_handler_for_problem {
my ($self, $row) = @_;
+ if ($row->to_body_named('TfL')) {
+ return FixMyStreet::Cobrand::TfL->new;
+ }
+ # Do not do anything for Highways England here, as we don't want it to
+ # treat this as a cobrand for e.g. submit report emails made on .com
+
my @bodies = values %{$row->bodies};
- my %areas = map { %{$_->areas} } grep { $_->name ne 'TfL' } @bodies;
+ my %areas = map { %{$_->areas} } grep { $_->name !~ /TfL|Highways England/ } @bodies;
my $cobrand = FixMyStreet::Cobrand->body_handler(\%areas);
return $cobrand if $cobrand;
@@ -400,4 +413,13 @@ sub category_extra_hidden {
return $self->SUPER::category_extra_hidden($meta);
}
+sub report_new_munge_before_insert {
+ my ($self, $report) = @_;
+
+ if ($report->to_body_named('TfL')) {
+ my $tfl = FixMyStreet::Cobrand->get_class_for_moniker('tfl')->new();
+ $tfl->report_new_munge_before_insert($report);
+ }
+}
+
1;
diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm
index 1beafef73..21dd2d455 100644
--- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm
+++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm
@@ -5,6 +5,7 @@ use strict;
use warnings;
use Carp;
+use List::Util qw(min max);
use URI::Escape;
use LWP::Simple;
use URI;
@@ -15,6 +16,11 @@ sub is_council {
1;
}
+sub suggest_duplicates {
+ my $self = shift;
+ return $self->feature('suggest_duplicates');
+}
+
sub path_to_web_templates {
my $self = shift;
return [
@@ -43,17 +49,39 @@ sub restriction {
}
# UK cobrands assume that each MapIt area ID maps both ways with one
-# body. Except TfL.
+# body. Except TfL and Highways England.
sub body {
my $self = shift;
- my $body = FixMyStreet::DB->resultset('Body')->for_areas($self->council_area_id)->search({ name => { '!=', 'TfL' } })->first;
+ my $body = FixMyStreet::DB->resultset('Body')->for_areas($self->council_area_id)->search({ name => { 'not_in', ['TfL', 'Highways England'] } })->first;
return $body;
}
+sub cut_off_date { '' }
+
sub problems_restriction {
my ($self, $rs) = @_;
return $rs if FixMyStreet->staging_flag('skip_checks');
- return $rs->to_body($self->body);
+ $rs = $rs->to_body($self->body);
+ if (my $date = $self->cut_off_date) {
+ my $table = ref $rs eq 'FixMyStreet::DB::ResultSet::Nearby' ? 'problem' : 'me';
+ $rs = $rs->search({
+ "$table.confirmed" => { '>=', $date }
+ });
+ }
+ return $rs;
+}
+
+sub problems_sql_restriction {
+ my ($self, $item_table) = @_;
+ my $q = '';
+ if (!$self->is_two_tier && $item_table ne 'comment') {
+ my $body_id = $self->body->id;
+ $q .= "AND regexp_split_to_array(bodies_str, ',') && ARRAY['$body_id']";
+ }
+ if (my $date = $self->cut_off_date) {
+ $q .= " AND confirmed >= '$date'";
+ }
+ return $q;
}
sub problems_on_map_restriction {
@@ -96,16 +124,26 @@ sub users_restriction {
'me.id' => [ { -in => $problem_user_ids }, { -in => $update_user_ids } ],
];
if ($self->can('admin_user_domain')) {
- my $domain = $self->admin_user_domain;
- push @$or_query, email => { ilike => "%\@$domain" };
+ my @domains = $self->admin_user_domain;
+ @domains = map { { ilike => "%\@$_" } } @domains;
+ @domains = [ @domains ] if @domains > 1;
+ push @$or_query, email => @domains;
}
- return $rs->search($or_query);
+ my $query = {
+ is_superuser => 0,
+ -or => $or_query
+ };
+ return $rs->search($query);
}
sub base_url {
my $self = shift;
- my $base_url = FixMyStreet->config('BASE_URL');
+
+ my $base_url = $self->feature('base_url');
+ return $base_url if $base_url;
+
+ $base_url = FixMyStreet->config('BASE_URL');
my $u = $self->council_url;
if ( $base_url !~ /$u/ ) {
$base_url =~ s{(https?://)(?!www\.)}{$1$u.}g;
@@ -114,6 +152,11 @@ sub base_url {
return $base_url;
}
+sub example_places {
+ my $self = shift;
+ return $self->feature('example_places') || $self->next::method();
+}
+
sub enter_postcode_text {
my ($self) = @_;
return 'Enter a ' . $self->council_area . ' postcode, or street name and area';
@@ -129,6 +172,12 @@ sub area_check {
if ($council_match) {
return 1;
}
+ return ( 0, $self->area_check_error_message($params, $context) );
+}
+
+sub area_check_error_message {
+ my ( $self, $params, $context ) = @_;
+
my $url = 'https://www.fixmystreet.com/';
if ($context eq 'alert') {
$url .= 'alert';
@@ -140,9 +189,8 @@ sub area_check {
$url .= '?latitude=' . URI::Escape::uri_escape( $self->{c}->get_param('latitude') )
. '&amp;longitude=' . URI::Escape::uri_escape( $self->{c}->get_param('longitude') )
if $self->{c}->get_param('latitude');
- my $error_msg = "That location is not covered by " . $self->council_name . ".
+ return "That location is not covered by " . $self->council_name . ".
Please visit <a href=\"$url\">the main FixMyStreet site</a>.";
- return ( 0, $error_msg );
}
# All reports page only has the one council.
@@ -154,8 +202,19 @@ sub all_reports_single_body {
sub reports_body_check {
my ( $self, $c, $code ) = @_;
+ # Deal with Bexley/Greenwich name not starting with short name
+ if ($code =~ /bexley|greenwich/i) {
+ my $body = $c->model('DB::Body')->search( { name => { -like => "%$code%" } } )->single;
+ $c->stash->{body} = $body;
+ return $body;
+ }
+
# We want to make sure we're only on our page.
- unless ( $self->council_name =~ /^\Q$code\E/ ) {
+ my $council_name = $self->council_name;
+ if (my $override = $self->all_reports_single_body) {
+ $council_name = $override->{name};
+ }
+ unless ( $council_name =~ /^\Q$code\E/ ) {
$c->res->redirect( 'https://www.fixmystreet.com' . $c->req->uri->path_query, 301 );
$c->detach();
}
@@ -180,8 +239,8 @@ sub owns_problem {
} else { # Object
@bodies = values %{$report->bodies};
}
- # Want to ignore the TfL body that covers London councils
- my %areas = map { %{$_->areas} } grep { $_->name ne 'TfL' } @bodies;
+ # Want to ignore the TfL body that covers London councils, and HE that is all England
+ my %areas = map { %{$_->areas} } grep { $_->name !~ /TfL|Highways England/ } @bodies;
return $areas{$self->council_area_id} ? 1 : undef;
}
@@ -206,16 +265,22 @@ sub base_url_for_report {
sub relative_url_for_report {
my ( $self, $report ) = @_;
- return $self->owns_problem($report) ? "" : FixMyStreet->config('BASE_URL');
+ return "" if $self->owns_problem($report);
+ return FixMyStreet::Cobrand::TfL->base_url if $report->cobrand eq 'tfl';
+ return FixMyStreet->config('BASE_URL');
}
sub admin_allow_user {
my ( $self, $user ) = @_;
return 1 if $user->is_superuser;
return undef unless defined $user->from_body;
+ # Make sure TfL staff can't access other London cobrand admins
+ return undef if $user->from_body->name eq 'TfL';
return $user->from_body->areas->{$self->council_area_id};
}
+sub admin_show_creation_graph { 0 }
+
sub available_permissions {
my $self = shift;
@@ -232,6 +297,39 @@ sub prefill_report_fields_for_inspector { 1 }
sub social_auth_disabled { 1 }
+sub munge_report_new_bodies {
+ my ($self, $bodies) = @_;
+
+ my %bodies = map { $_->name => 1 } values %$bodies;
+ if ( $bodies{'TfL'} ) {
+ # Presented categories vary if we're on/off a red route
+ my $tfl = FixMyStreet::Cobrand::TfL->new({ c => $self->{c} });
+ $tfl->munge_surrounding_london($bodies);
+ }
+
+ if ( $bodies{'Highways England'} ) {
+ my $c = $self->{c};
+ my $he = FixMyStreet::Cobrand::HighwaysEngland->new({ c => $c });
+ my $on_he_road = $c->stash->{on_he_road} = $he->report_new_is_on_he_road;
+
+ if (!$on_he_road) {
+ %$bodies = map { $_->id => $_ } grep { $_->name ne 'Highways England' } values %$bodies;
+ }
+ }
+}
+
+sub munge_report_new_contacts {
+ my ($self, $contacts) = @_;
+
+ my %bodies = map { $_->body->name => $_->body } @$contacts;
+ if ( $bodies{'TfL'} ) {
+ # Presented categories vary if we're on/off a red route
+ my $tfl = FixMyStreet::Cobrand->get_class_for_moniker( 'tfl' )->new({ c => $self->{c} });
+ $tfl->munge_red_route_categories($contacts);
+ }
+}
+
+
=head2 lookup_site_code
Reports made via FMS.com or the app probably won't have a site code
@@ -249,13 +347,43 @@ see Buckinghamshire or Lincolnshire for an example.
sub lookup_site_code {
my $self = shift;
my $row = shift;
- my $buffer = shift;
-
- my $cfg = $self->lookup_site_code_config;
+ my $field = shift;
- $buffer ||= $cfg->{buffer}; # metres
+ my $cfg = $self->lookup_site_code_config($field);
my ($x, $y) = $row->local_coords;
- my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer);
+
+ my $features = $self->_fetch_features($cfg, $x, $y);
+ return $self->_nearest_feature($cfg, $x, $y, $features);
+}
+
+sub _fetch_features {
+ my ($self, $cfg, $x, $y) = @_;
+
+ # default to a buffered bounding box around the given point unless
+ # a custom filter parameter has been specified.
+ unless ( $cfg->{filter} ) {
+ my $buffer = $cfg->{buffer};
+ my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer);
+ $cfg->{bbox} = "$w,$s,$e,$n";
+ }
+
+ my $uri = $self->_fetch_features_url($cfg);
+ my $response = get($uri) or return;
+
+ my $j = JSON->new->utf8->allow_nonref;
+ try {
+ $j = $j->decode($response);
+ } catch {
+ # There was either no asset found, or an error with the WFS
+ # call - in either case let's just proceed without the USRN.
+ return;
+ };
+
+ return $j->{features};
+}
+
+sub _fetch_features_url {
+ my ($self, $cfg) = @_;
my $uri = URI->new($cfg->{url});
$uri->query_form(
@@ -265,45 +393,48 @@ sub lookup_site_code {
TYPENAME => $cfg->{typename},
VERSION => "1.1.0",
outputformat => "geojson",
- BBOX => "$w,$s,$e,$n"
+ $cfg->{filter} ? ( Filter => $cfg->{filter} ) : ( BBOX => $cfg->{bbox} ),
);
- my $response = get($uri);
+ return $uri;
+}
- my $j = JSON->new->utf8->allow_nonref;
- try {
- $j = $j->decode($response);
- } catch {
- # There was either no asset found, or an error with the WFS
- # call - in either case let's just proceed without the USRN.
- return '';
- };
+
+sub _nearest_feature {
+ my ($self, $cfg, $x, $y, $features) = @_;
# We have a list of features, and we want to find the one closest to the
# report location.
my $site_code = '';
my $nearest;
- for my $feature ( @{ $j->{features} } ) {
- next unless $cfg->{accept_feature}($feature);
+ # We shouldn't receive anything aside from these geometry types, but belt and braces.
+ my $accept_types = $cfg->{accept_types} || {
+ LineString => 1,
+ MultiLineString => 1
+ };
- # We shouldn't receive anything aside from these two geometry types, but belt and braces.
- next unless $feature->{geometry}->{type} eq 'MultiLineString' || $feature->{geometry}->{type} eq 'LineString';
+ for my $feature ( @{$features || []} ) {
+ next unless $cfg->{accept_feature}($feature);
+ next unless $accept_types->{$feature->{geometry}->{type}};
- my @coordinates = @{ $feature->{geometry}->{coordinates} };
- if ( $feature->{geometry}->{type} eq 'MultiLineString') {
- # The coordinates are stored as a list of lists, so flatten 'em out
- @coordinates = map { @{ $_ } } @coordinates;
+ my @linestrings = @{ $feature->{geometry}->{coordinates} };
+ if ( $feature->{geometry}->{type} eq 'LineString' ) {
+ @linestrings = ([ @linestrings ]);
+ }
+ # If it is a point, upgrade it to a one-segment zero-length
+ # MultiLineString so it can be compared by the distance function.
+ if ( $feature->{geometry}->{type} eq 'Point') {
+ @linestrings = ([ [ @linestrings ], [ @linestrings ] ]);
}
- # If any of this feature's points are closer than those we've seen so
- # far then use the site_code from this feature.
- for my $coords ( @coordinates ) {
- my ($fx, $fy) = @$coords;
- my $distance = $self->_distance($x, $y, $fx, $fy);
- if ( !defined $nearest || $distance < $nearest ) {
- $site_code = $feature->{properties}->{$cfg->{property}};
- $nearest = $distance;
+ foreach my $coordinates (@linestrings) {
+ for (my $i=0; $i<@$coordinates-1; $i++) {
+ my $distance = $self->_distanceToLine($x, $y, $coordinates->[$i], $coordinates->[$i+1]);
+ if ( !defined $nearest || $distance < $nearest ) {
+ $site_code = $feature->{properties}->{$cfg->{property}};
+ $nearest = $distance;
+ }
}
}
}
@@ -311,6 +442,43 @@ sub lookup_site_code {
return $site_code;
}
+sub contact_name {
+ my $self = shift;
+ return $self->feature('contact_name') || $self->next::method();
+}
+
+sub contact_email {
+ my $self = shift;
+ return $self->feature('contact_email') || $self->next::method();
+}
+
+# Allow cobrands to disallow updates on some things.
+# Note this only ever locks down more than the default.
+sub updates_disallowed {
+ my $self = shift;
+ my ($problem) = @_;
+ my $c = $self->{c};
+
+ my $cfg = $self->feature('updates_allowed') || '';
+ if ($cfg eq 'none') {
+ return 1;
+ } elsif ($cfg eq 'staff') {
+ # Only staff and superusers can leave updates
+ my $staff = $c->user_exists && $c->user->from_body && $c->user->from_body->name eq $self->council_name;
+ my $superuser = $c->user_exists && $c->user->is_superuser;
+ return 1 unless $staff || $superuser;
+ }
+
+ if ($cfg =~ /reporter/) {
+ return 1 if !$c->user_exists || $c->user->id != $problem->user->id;
+ }
+ if ($cfg =~ /open/) {
+ return 1 if $problem->is_fixed || $problem->is_closed;
+ }
+
+ return $self->next::method(@_);
+}
+
sub extra_contact_validation {
my $self = shift;
my $c = shift;
@@ -332,18 +500,24 @@ sub extra_contact_validation {
}
-=head2 _distance
+=head2 _distanceToLine
-Returns the cartesian distance between two coordinates.
+Returns the cartesian distance of a point from a line.
This is not a general-purpose distance function, it's intended for use with
fairly nearby coordinates in EPSG:27700 where a spheroid doesn't need to be
taken into account.
=cut
-sub _distance {
- my ($self, $ax, $ay, $bx, $by) = @_;
- return sqrt( (($ax - $bx) ** 2) + (($ay - $by) ** 2) );
-}
+sub _distanceToLine {
+ my ($self, $x, $y, $start, $end) = @_;
+ my $dx = $end->[0] - $start->[0];
+ my $dy = $end->[1] - $start->[1];
+ my $along = ($dx == 0 && $dy == 0) ? 0 : (($dx * ($x - $start->[0])) + ($dy * ($y - $start->[1]))) / ($dx**2 + $dy**2);
+ $along = max(0, min(1, $along));
+ my $fx = $start->[0] + $along * $dx;
+ my $fy = $start->[1] + $along * $dy;
+ return sqrt( (($x - $fx) ** 2) + (($y - $fy) ** 2) );
+}
1;
diff --git a/perllib/FixMyStreet/Cobrand/Warwickshire.pm b/perllib/FixMyStreet/Cobrand/Warwickshire.pm
index c301450bc..1bdf919da 100644
--- a/perllib/FixMyStreet/Cobrand/Warwickshire.pm
+++ b/perllib/FixMyStreet/Cobrand/Warwickshire.pm
@@ -1,5 +1,5 @@
package FixMyStreet::Cobrand::Warwickshire;
-use base 'FixMyStreet::Cobrand::UKCouncils';
+use base 'FixMyStreet::Cobrand::Whitelabel';
use strict;
use warnings;
@@ -22,16 +22,6 @@ sub disambiguate_location {
};
}
-sub example_places {
- return [ 'CV34 4RL', 'Stratford Rd' ];
-}
-
-sub contact_email {
- my $self = shift;
- return join( '@', 'fmstest', 'warwickshire.gov.uk' );
-}
-sub contact_name { 'Warwickshire County Council (do not reply)'; }
-
sub send_questionnaires { 0 }
sub open311_contact_meta_override {
diff --git a/perllib/FixMyStreet/Cobrand/Westminster.pm b/perllib/FixMyStreet/Cobrand/Westminster.pm
new file mode 100644
index 000000000..c9f31f7f9
--- /dev/null
+++ b/perllib/FixMyStreet/Cobrand/Westminster.pm
@@ -0,0 +1,167 @@
+package FixMyStreet::Cobrand::Westminster;
+use base 'FixMyStreet::Cobrand::Whitelabel';
+
+use strict;
+use warnings;
+
+use URI;
+
+sub council_area_id { return 2504; }
+sub council_area { return 'Westminster'; }
+sub council_name { return 'Westminster City Council'; }
+sub council_url { return 'Westminster'; }
+
+sub disambiguate_location {
+ my $self = shift;
+ return {
+ %{ $self->SUPER::disambiguate_location() },
+ town => 'Westminster',
+ centre => '51.513444,-0.160467',
+ bounds => [ 51.483816, -0.216088, 51.539793, -0.111101 ],
+ };
+}
+
+sub get_geocoder {
+ return 'OSM'; # default of Bing gives poor results, let's try overriding.
+}
+
+sub enter_postcode_text {
+ my ($self) = @_;
+ return 'Enter a ' . $self->council_area . ' postcode, or street name';
+}
+
+sub send_questionnaires { 0 }
+
+sub suppress_reporter_alerts { 1 }
+
+sub report_age { '3 months' }
+
+sub on_map_default_status { 'open' }
+
+sub social_auth_enabled {
+ my $self = shift;
+
+ return $self->feature('oidc_login') ? 1 : 0;
+}
+
+sub allow_anonymous_reports { 'button' }
+
+sub admin_user_domain { 'westminster.gov.uk' }
+
+sub anonymous_account {
+ my $self = shift;
+ return {
+ email => $self->feature('anonymous_account') . '@' . $self->admin_user_domain,
+ name => 'Anonymous user',
+ };
+}
+
+sub oidc_user_extra {
+ my ($self, $id_token) = @_;
+
+ # Westminster want the CRM ID of the user to be passed in the
+ # account_id field of Open311 POST Service Requests, so
+ # extract it from the id token and store in user extra
+ # if it's available.
+ my $crm_id = $id_token->payload->{extension_CrmContactId};
+
+ return {
+ $crm_id ? (westminster_account_id => $crm_id) : (),
+ };
+}
+
+sub open311_config {
+ my ($self, $row, $h, $params) = @_;
+
+ my $id = $row->user->get_extra_metadata('westminster_account_id');
+ # Westminster require 0 as the account ID if there's no MyWestminster ID.
+ $h->{account_id} = $id || '0';
+}
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
+
+ # Reports made via the app probably won't have a USRN because we don't
+ # display the road layer. Instead we'll look up the closest asset from the
+ # asset service at the point we're sending the report over Open311.
+ if (!$row->get_extra_field_value('USRN')) {
+ if (my $ref = $self->lookup_site_code($row, 'USRN')) {
+ push @$extra, { name => 'USRN', value => $ref };
+ }
+ }
+
+ # Some categories require a UPRN to be set, so if the field is present
+ # but empty then look it up.
+ my $fields = $row->get_extra_fields;
+ my ($uprn_field) = grep { $_->{name} eq 'UPRN' } @$fields;
+ if ( $uprn_field && !$uprn_field->{value} ) {
+ if (my $ref = $self->lookup_site_code($row, 'UPRN')) {
+ push @$extra, { name => 'UPRN', value => $ref };
+ }
+ }
+
+ return undef;
+}
+
+sub lookup_site_code_config {
+ my ( $self, $field ) = @_;
+ # uncoverable subroutine
+ # uncoverable statement
+ my $layer = $field eq 'USRN' ? '40' : '25'; # 25 is UPRN
+
+ my %cfg = (
+ buffer => 1000, # metres
+ proxy_url => "https://tilma.mysociety.org/resource-proxy/proxy.php",
+ url => "https://westminster.assets/$layer/query",
+ property => $field,
+ accept_feature => sub { 1 },
+
+ # UPRNs are Point geometries, so make sure they're allowed by
+ # _nearest_feature.
+ ( $field eq 'UPRN' ) ? (accept_types => { Point => 1 }) : (),
+ );
+ return \%cfg;
+}
+
+sub _fetch_features_url {
+ my ($self, $cfg) = @_;
+
+ # Westminster's asset proxy has a slightly different calling style to
+ # a standard WFS server.
+ my $uri = URI->new($cfg->{url});
+ $uri->query_form(
+ inSR => "27700",
+ outSR => "27700",
+ f => "geojson",
+ outFields => $cfg->{property},
+ geometry => $cfg->{bbox},
+ );
+
+ return $cfg->{proxy_url} . "?" . $uri->as_string;
+}
+
+sub categories_restriction {
+ my ($self, $rs) = @_;
+ # Westminster don't want TfL or email categories on their cobrand.
+ # Categories covering the council area have a mixture of Open311 and Email
+ # send methods. We've set up the Email categories with a devolved
+ # send_method, so can identify Open311 categories as those which have a
+ # blank send_method.
+ # XXX This still shows "These will be sent to TfL or Westminster City Council"
+ # on /report/new before a category is selected...
+ return $rs->search( {
+ 'body.name' => 'Westminster City Council',
+ 'me.send_method' => undef,
+ }, { join => 'body' });
+}
+
+sub updates_restriction {
+ my $self = shift;
+
+ # Westminster don't want any fms.com updates shown on their cobrand.
+ return $self->next::method(@_)->search({
+ "me.cobrand" => { '!=', 'fixmystreet' }
+ });
+}
+
+1;
diff --git a/perllib/FixMyStreet/Cobrand/Zurich.pm b/perllib/FixMyStreet/Cobrand/Zurich.pm
index 9b6a3b9cb..3cf678f9c 100644
--- a/perllib/FixMyStreet/Cobrand/Zurich.pm
+++ b/perllib/FixMyStreet/Cobrand/Zurich.pm
@@ -9,6 +9,8 @@ use Scalar::Util 'blessed';
use DateTime::Format::Pg;
use Try::Tiny;
+use FixMyStreet::Geocode::Zurich;
+
use strict;
use warnings;
use utf8;
@@ -78,7 +80,7 @@ sub find_closest {
sub enter_postcode_text {
my ( $self ) = @_;
- return _('Enter a Z&uuml;rich street name');
+ return _('Enter a Zürich street name');
}
sub example_places {
@@ -141,7 +143,7 @@ sub problem_as_hashref {
$hashref->{title} = _('This report is awaiting moderation.');
$hashref->{banner_id} = 'closed';
} else {
- if ( $problem->state eq 'confirmed' || $problem->state eq 'external' ) {
+ if ( $problem->state eq 'confirmed' ) {
$hashref->{banner_id} = 'closed';
} elsif ( $problem->is_fixed || $problem->is_closed ) {
$hashref->{banner_id} = 'fixed';
@@ -152,7 +154,7 @@ sub problem_as_hashref {
if ( $problem->state eq 'confirmed' ) {
$hashref->{state} = 'open';
$hashref->{state_t} = _('Open');
- } elsif ( $problem->state eq 'wish' ) {
+ } elsif ( $problem->state eq 'wish' || $problem->state eq 'external' ) {
$hashref->{state_t} = _('Closed');
} elsif ( $problem->is_fixed ) {
$hashref->{state} = 'closed';
@@ -322,6 +324,7 @@ sub report_page_data {
$c->stash->{page} = 'reports';
$c->forward( 'stash_report_filter_status' );
+ $c->forward('stash_report_sort', [ $c->cobrand->reports_ordering ]);
$c->forward( 'load_and_group_problems' );
$c->stash->{body} = { id => 0 }; # So template can fetch the list
@@ -329,6 +332,13 @@ sub report_page_data {
$c->detach('ajax', [ 'reports/_problem-list.html' ]);
}
+ my @categories = $c->model('DB::Contact')->not_deleted->search(undef, {
+ columns => [ 'category', 'extra' ],
+ distinct => 1
+ })->all_sorted;
+ $c->stash->{filter_categories} = \@categories;
+ $c->stash->{filter_category} = { map { $_ => 1 } $c->get_param_list('filter_category', 1) };
+
my $pins = $c->stash->{pins};
FixMyStreet::Map::display_map(
$c,
@@ -354,7 +364,7 @@ sub set_problem_state {
my ($self, $c, $problem, $new_state) = @_;
return $self->update_admin_log($c, $problem) if $new_state eq $problem->state;
$problem->state( $new_state );
- $c->forward( 'log_edit', [ $problem->id, 'problem', "state change to $new_state" ] );
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', "state change to $new_state" ] );
}
=head1 C<update_admin_log>
@@ -378,7 +388,7 @@ sub update_admin_log {
$text = "Logging time_spent";
}
- $c->forward( 'log_edit', [ $problem->id, 'problem', $text, $time_spent ] );
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem', $text, $time_spent ] );
}
# Any user with from_body set can view admin
@@ -460,7 +470,7 @@ sub admin {
my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1;
$c->stash->{order} = $order;
$c->stash->{dir} = $dir;
- $order .= ' desc' if $dir;
+ $order = { -desc => $order } if $dir;
# XXX No multiples or missing bodies
$c->stash->{submitted} = $c->cobrand->problems->search({
@@ -494,7 +504,7 @@ sub admin {
my $dir = defined $c->get_param('d') ? $c->get_param('d') : 1;
$c->stash->{order} = $order;
$c->stash->{dir} = $dir;
- $order .= ' desc' if $dir;
+ $order = { -desc => $order } if $dir;
# XXX No multiples or missing bodies
$c->stash->{reports_new} = $c->cobrand->problems->search( {
@@ -522,12 +532,16 @@ sub admin {
}
sub category_options {
- my ($self, $c) = @_;
+ my $self = shift;
+ my $c = $self->{c};
my @categories = $c->model('DB::Contact')->not_deleted->all;
- $c->stash->{category_options} = [ map { {
- category => $_->category, category_display => $_->category,
+ @categories = map { {
+ category => $_->category,
+ category_display => $_->get_extra_metadata('admin_label') || $_->category,
abbreviation => $_->get_extra_metadata('abbreviation'),
- } } @categories ];
+ } } @categories;
+ @categories = sort { $a->{category_display} cmp $b->{category_display} } @categories;
+ $c->stash->{category_options} = \@categories;
}
sub admin_report_edit {
@@ -553,21 +567,39 @@ sub admin_report_edit {
$c->stash->{bodies} = \@bodies;
# Can change category to any other
- $self->category_options($c);
+ $self->category_options;
} elsif ($type eq 'dm') {
# Can assign to:
my @bodies = $c->model('DB::Body')->search( [
- { 'me.parent' => $body->parent->id }, # Other DMs on the same level
{ 'me.parent' => $body->id }, # Their subdivisions
{ 'me.parent' => undef, 'bodies.id' => undef }, # External bodies
- ], { join => 'bodies', distinct => 1 } );
- @bodies = sort { strcoll($a->name, $b->name) } @bodies;
+ ], { join => 'bodies', distinct => 1 } )->all;
+ @bodies = grep {
+ my $cat = $_->get_extra_metadata('category');
+ if ($cat) {
+ $cat = $c->model('DB::Contact')->not_deleted->search({ category => $cat })->first;
+ }
+ !$cat || $cat->body_id == $body->id;
+ } @bodies;
+ @bodies = sort {
+ my $a_cat = $a->get_extra_metadata('category');
+ my $b_cat = $b->get_extra_metadata('category');
+ if ($a_cat && $b_cat) {
+ strcoll($a->name, $b->name)
+ } elsif ($a_cat) {
+ -1;
+ } elsif ($b_cat) {
+ 1;
+ } else {
+ strcoll($a->name, $b->name)
+ }
+ } @bodies;
$c->stash->{bodies} = \@bodies;
# Can change category to any other
- $self->category_options($c);
+ $self->category_options;
}
@@ -866,7 +898,7 @@ sub admin_report_edit {
$self->set_problem_state($c, $problem, 'confirmed');
}
$problem->update;
- $c->forward( 'log_edit', [ $problem->id, 'problem',
+ $c->forward( '/admin/log_edit', [ $problem->id, 'problem',
$not_contactable ?
_('Customer not contactable')
: _('Sent report back') ] );
@@ -927,6 +959,11 @@ sub admin_report_edit {
}
+sub admin_district_lookup {
+ my ($self, $row) = @_;
+ FixMyStreet::Geocode::Zurich::admin_district($row->local_coords);
+}
+
sub stash_states {
my ($self, $problem) = @_;
my $c = $self->{c};
@@ -1116,7 +1153,7 @@ sub admin_stats {
if ($y && $m) {
$c->stash->{start_date} = DateTime->new( year => $y, month => $m, day => 1 );
$c->stash->{end_date} = $c->stash->{start_date} + DateTime::Duration->new( months => 1 );
- $optional_params{created} = {
+ $optional_params{'me.created'} = {
'>=', DateTime::Format::Pg->format_datetime($c->stash->{start_date}),
'<', DateTime::Format::Pg->format_datetime($c->stash->{end_date}),
};
@@ -1135,7 +1172,7 @@ sub admin_stats {
}
# Can change category to any other
- $self->category_options($c);
+ $self->category_options;
# Total reports (non-hidden)
my $total = $c->model('DB::Problem')->search( \%params )->count;
@@ -1202,100 +1239,94 @@ sub admin_stats {
sub export_as_csv {
my ($self, $c, $params) = @_;
- try {
- $c->model('DB')->schema->storage->sql_maker->quote_char('"');
- my $csv = $c->stash->{csv} = {
- objects => $c->model('DB::Problem')->search_rs(
- $params,
- {
- join => ['admin_log_entries', 'user'],
- distinct => 1,
- columns => [
- 'id', 'created',
- 'latitude', 'longitude',
- 'cobrand', 'category',
- 'state', 'user_id',
- 'external_body',
- 'title', 'detail',
- 'photo',
- 'whensent', 'lastupdate',
- 'service',
- 'extra',
- { sum_time_spent => { sum => 'admin_log_entries.time_spent' } },
- 'name', 'user.id', 'user.email', 'user.phone', 'user.name',
- ]
- }
- ),
- headers => [
- 'Report ID', 'Created', 'Sent to Agency', 'Last Updated',
- 'E', 'N', 'Category', 'Status', 'Closure Status',
- 'UserID', 'User email', 'User phone', 'User name',
- 'External Body', 'Time Spent', 'Title', 'Detail',
- 'Media URL', 'Interface Used', 'Council Response',
- 'Strasse', 'Mast-Nr.', 'Haus-Nr.', 'Hydranten-Nr.',
- ],
- columns => [
- 'id', 'created', 'whensent',' lastupdate', 'local_coords_x',
- 'local_coords_y', 'category', 'state', 'closure_status',
- 'user_id', 'user_email', 'user_phone', 'user_name',
- 'body_name', 'sum_time_spent', 'title', 'detail',
- 'media_url', 'service', 'public_response',
- 'strasse', 'mast_nr',' haus_nr', 'hydranten_nr',
- ],
- extra_data => sub {
- my $report = shift;
-
- my $body_name = "";
- if ( my $external_body = $report->body($c) ) {
- $body_name = $external_body->name || '[Unknown body]';
- }
- my $detail = $report->detail;
- my $public_response = $report->get_extra_metadata('public_response') || '';
- my $metas = $report->get_extra_fields();
- my %extras;
- foreach my $field (@$metas) {
- $extras{$field->{name}} = $field->{value};
- }
+ my $csv = $c->stash->{csv} = {
+ objects => $c->model('DB::Problem')->search_rs(
+ $params,
+ {
+ join => ['admin_log_entries', 'user'],
+ distinct => 1,
+ columns => [
+ 'id', 'created',
+ 'latitude', 'longitude',
+ 'cobrand', 'category',
+ 'state', 'user_id',
+ 'external_body',
+ 'title', 'detail',
+ 'photo',
+ 'whensent', 'lastupdate',
+ 'service',
+ 'extra',
+ { sum_time_spent => { sum => 'admin_log_entries.time_spent' } },
+ 'name', 'user.id', 'user.email', 'user.phone', 'user.name',
+ ]
+ }
+ ),
+ headers => [
+ 'Report ID', 'Created', 'Sent to Agency', 'Last Updated',
+ 'E', 'N', 'Category', 'Status', 'Closure Status',
+ 'UserID', 'User email', 'User phone', 'User name',
+ 'External Body', 'Time Spent', 'Title', 'Detail',
+ 'Media URL', 'Interface Used', 'Council Response',
+ 'Strasse', 'Mast-Nr.', 'Haus-Nr.', 'Hydranten-Nr.',
+ ],
+ columns => [
+ 'id', 'created', 'whensent',' lastupdate', 'local_coords_x',
+ 'local_coords_y', 'category', 'state', 'closure_status',
+ 'user_id', 'user_email', 'user_phone', 'user_name',
+ 'body_name', 'sum_time_spent', 'title', 'detail',
+ 'media_url', 'service', 'public_response',
+ 'strasse', 'mast_nr',' haus_nr', 'hydranten_nr',
+ ],
+ extra_data => sub {
+ my $report = shift;
+
+ my $body_name = "";
+ if ( my $external_body = $report->body($c) ) {
+ $body_name = $external_body->name || '[Unknown body]';
+ }
- # replace newlines with HTML <br/> element
- $detail =~ s{\r?\n}{ <br/> }g;
- $public_response =~ s{\r?\n}{ <br/> }g if $public_response;
-
- # Assemble photo URL, if report has a photo
- my $photo_to_display = $c->cobrand->allow_photo_display($report);
- my $media_url = (@{$report->photos} && $photo_to_display)
- ? $c->cobrand->base_url . $report->photos->[$photo_to_display-1]->{url}
- : '';
-
- return {
- whensent => $report->whensent,
- lastupdate => $report->lastupdate,
- user_id => $report->user_id,
- user_email => $report->user->email || '',
- user_phone => $report->user->phone || '',
- user_name => $report->name,
- closure_status => $report->get_extra_metadata('closure_status') || '',
- body_name => $body_name,
- sum_time_spent => $report->get_column('sum_time_spent') || 0,
- detail => $detail,
- media_url => $media_url,
- service => $report->service || 'Web interface',
- public_response => $public_response,
- strasse => $extras{'strasse'} || '',
- mast_nr => $extras{'mast_nr'} || '',
- haus_nr => $extras{'haus_nr'} || '',
- hydranten_nr => $extras{'hydranten_nr'} || ''
- };
- },
- filename => 'stats',
- };
- $c->forward('/dashboard/generate_csv');
- } catch {
- die $_;
- } finally {
- $c->model('DB')->schema->storage->sql_maker->quote_char('');
+ my $detail = $report->detail;
+ my $public_response = $report->get_extra_metadata('public_response') || '';
+ my $metas = $report->get_extra_fields();
+ my %extras;
+ foreach my $field (@$metas) {
+ $extras{$field->{name}} = $field->{value};
+ }
+
+ # replace newlines with HTML <br/> element
+ $detail =~ s{\r?\n}{ <br/> }g;
+ $public_response =~ s{\r?\n}{ <br/> }g if $public_response;
+
+ # Assemble photo URL, if report has a photo
+ my $photo_to_display = $c->cobrand->allow_photo_display($report);
+ my $media_url = (@{$report->photos} && $photo_to_display)
+ ? $c->cobrand->base_url . $report->photos->[$photo_to_display-1]->{url}
+ : '';
+
+ return {
+ whensent => $report->whensent,
+ lastupdate => $report->lastupdate,
+ user_id => $report->user_id,
+ user_email => $report->user->email || '',
+ user_phone => $report->user->phone || '',
+ user_name => $report->name,
+ closure_status => $report->get_extra_metadata('closure_status') || '',
+ body_name => $body_name,
+ sum_time_spent => $report->get_column('sum_time_spent') || 0,
+ detail => $detail,
+ media_url => $media_url,
+ service => $report->service || 'Web interface',
+ public_response => $public_response,
+ strasse => $extras{'strasse'} || '',
+ mast_nr => $extras{'mast_nr'} || '',
+ haus_nr => $extras{'haus_nr'} || '',
+ hydranten_nr => $extras{'hydranten_nr'} || ''
+ };
+ },
+ filename => 'stats',
};
+ $c->forward('/dashboard/generate_csv');
}
sub problem_confirm_email_extras {
@@ -1311,7 +1342,9 @@ sub reports_per_page { return 20; }
sub singleton_bodies_str { 1 }
-sub contact_extra_fields { [ 'abbreviation' ] };
+sub body_extra_fields { [ 'category' ] };
+
+sub contact_extra_fields { [ 'abbreviation', 'admin_label' ] };
sub default_problem_state { 'submitted' }
@@ -1349,4 +1382,11 @@ sub db_state_migration {
}
}
+sub hook_report_filter_status {
+ my ($self, $status) = @_;
+ @$status = map {
+ $_ eq 'closed' ? ('closed', 'fixed') : $_
+ } @$status;
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/Factories.pm b/perllib/FixMyStreet/DB/Factories.pm
index 5af9ed38f..05c56e394 100644
--- a/perllib/FixMyStreet/DB/Factories.pm
+++ b/perllib/FixMyStreet/DB/Factories.pm
@@ -1,6 +1,7 @@
use strict;
use warnings;
use v5.14;
+use utf8;
use FixMyStreet::DB;
diff --git a/perllib/FixMyStreet/DB/RABXColumn.pm b/perllib/FixMyStreet/DB/RABXColumn.pm
index d14b48dc8..76eb21030 100644
--- a/perllib/FixMyStreet/DB/RABXColumn.pm
+++ b/perllib/FixMyStreet/DB/RABXColumn.pm
@@ -52,6 +52,8 @@ set_filtered_column behaviour to not trust the cache.
sub rabx_column {
my ($class, $col) = @_;
+ my $data_type = $class->column_info($col)->{data_type};
+
# Apply the filtering for this column
$class->filter_column(
$col => {
@@ -59,6 +61,10 @@ sub rabx_column {
my $self = shift;
my $ser = shift;
return undef unless defined $ser;
+ # Some RABX columns are text, when they should be bytea. For
+ # these we must re-encode the string returned from the
+ # database, so that it is decoded again by RABX.
+ utf8::encode($ser) if $data_type ne 'bytea';
my $h = new IO::String($ser);
return RABX::wire_rd($h);
},
@@ -68,6 +74,10 @@ sub rabx_column {
my $ser = '';
my $h = new IO::String($ser);
RABX::wire_wr( $data, $h );
+ # Some RABX columns are text, when they should be bytea. For
+ # these, we must re-decode the string encoded by RABX, so that
+ # it is encoded again when saved to the db.
+ utf8::decode($ser) if $data_type ne 'bytea';
return $ser;
},
}
@@ -77,14 +87,6 @@ sub rabx_column {
$RABX_COLUMNS{ _get_class_identifier($class) }{$col} = 1;
}
-# The underlying column should always be UTF-8 encoded bytes.
-sub get_column {
- my ($self, $col) = @_;
- my $res = $self->next::method ($col);
- utf8::encode($res) if $RABX_COLUMNS{_get_class_identifier($self)}{$col} && utf8::is_utf8($res);
- return $res;
-}
-
sub set_filtered_column {
my ($self, $col, $val) = @_;
diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm
index 221690405..4c89138c9 100644
--- a/perllib/FixMyStreet/DB/Result/AdminLog.pm
+++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm
@@ -61,4 +61,85 @@ __PACKAGE__->belongs_to(
# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BLPP1KitphuY56ptaXhzgg
+sub link {
+ my $self = shift;
+
+ my $type = $self->object_type;
+ my $id = $self->object_id;
+ return "/report/$id" if $type eq 'problem';
+ return "/admin/users/$id" if $type eq 'user';
+ return "/admin/body/$id" if $type eq 'body';
+ return "/admin/roles/$id" if $type eq 'role';
+ if ($type eq 'update') {
+ my $update = $self->object;
+ return "/report/" . $update->problem_id . "#update_$id";
+ }
+ if ($type eq 'moderation') {
+ my $mod = $self->object;
+ if ($mod->comment_id) {
+ my $update = $self->result_source->schema->resultset('Comment')->find($mod->comment_id);
+ return "/report/" . $update->problem_id . "#update_" . $mod->comment_id;
+ } else {
+ return "/report/" . $mod->problem_id;
+ }
+ }
+ if ($type eq 'template') {
+ my $template = $self->object;
+ return "/admin/templates/" . $template->body_id . "/$id";
+ }
+ if ($type eq 'category') {
+ my $category = $self->object;
+ return "/admin/body/" . $category->body_id . '/' . $category->category;
+ }
+ if ($type eq 'manifesttheme') {
+ my $theme = $self->object;
+ return "/admin/manifesttheme/" . $theme->cobrand;
+ }
+ return '';
+}
+
+sub actual_object_type {
+ my $self = shift;
+ my $type = $self->object_type;
+ return $type unless $type eq 'moderation' && $self->object;
+ return $self->object->comment_id ? 'update' : 'report';
+}
+
+sub object_summary {
+ my $self = shift;
+ my $object = $self->object;
+ return unless $object;
+
+ return $object->comment_id || $object->problem_id if $self->object_type eq 'moderation';
+ return $object->email || $object->phone || $object->id if $self->object_type eq 'user';
+
+ my $type_to_thing = {
+ body => 'name',
+ role => 'name',
+ template => 'title',
+ category => 'category',
+ manifesttheme => 'cobrand',
+ };
+ my $thing = $type_to_thing->{$self->object_type} || 'id';
+
+ return $object->$thing;
+}
+
+sub object {
+ my $self = shift;
+
+ my $type = $self->object_type;
+ my $id = $self->object_id;
+ my $type_to_object = {
+ moderation => 'ModerationOriginalData',
+ template => 'ResponseTemplate',
+ category => 'Contact',
+ update => 'Comment',
+ manifesttheme => 'ManifestTheme',
+ };
+ $type = $type_to_object->{$type} || ucfirst $type;
+ my $object = $self->result_source->schema->resultset($type)->find($id);
+ return $object;
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/Result/Body.pm b/perllib/FixMyStreet/DB/Result/Body.pm
index 663181746..95debc910 100644
--- a/perllib/FixMyStreet/DB/Result/Body.pm
+++ b/perllib/FixMyStreet/DB/Result/Body.pm
@@ -117,6 +117,12 @@ __PACKAGE__->has_many(
{ cascade_copy => 0, cascade_delete => 0 },
);
__PACKAGE__->has_many(
+ "roles",
+ "FixMyStreet::DB::Result::Role",
+ { "foreign.body_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+__PACKAGE__->has_many(
"user_body_permissions",
"FixMyStreet::DB::Result::UserBodyPermission",
{ "foreign.body_id" => "self.id" },
@@ -130,8 +136,8 @@ __PACKAGE__->has_many(
);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:8CuxbffDaYS7TFlgff1nEg
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:9sFgYQ9qhnZNcz3kUFYuvg
__PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn");
__PACKAGE__->rabx_column('extra');
@@ -187,11 +193,7 @@ sub first_area_children {
my $cobrand = $self->result_source->schema->cobrand;
- my $children = FixMyStreet::MapIt::call('area/children', $body_area->area_id,
- type => $cobrand->area_types_children,
- );
-
- return $children;
+ return $cobrand->fetch_area_children($body_area->area_id);
}
=head2 get_cobrand_handler
@@ -209,6 +211,33 @@ sub get_cobrand_handler {
return FixMyStreet::Cobrand->body_handler($self->areas);
}
+=item
+
+If get_cobrand_handler returns a cobrand, and that cobrand
+has a council_name, use it in preference to the body name.
+
+=cut
+
+sub cobrand_name {
+ my $self = shift;
+
+ # Because TfL covers all the boroughs in London, get_cobrand_handler
+ # may return another London cobrand if it is listed before tfl in
+ # ALLOWED_COBRANDS, because one of this body's area_ids will also
+ # match that cobrand's council_area_id. This leads to odd things like
+ # councils_text_all.html showing a message like "These will be sent to
+ # Bromley Council" when making a report within Westminster on the TfL
+ # cobrand.
+ # If the current body is TfL then we always want to show TfL as the cobrand name.
+ return $self->name if $self->name eq 'TfL' || $self->name eq 'Highways England';
+
+ my $handler = $self->get_cobrand_handler;
+ if ($handler && $handler->can('council_name')) {
+ return $handler->council_name;
+ }
+ return $self->name;
+}
+
sub calculate_average {
my ($self, $threshold) = @_;
$threshold ||= 0;
@@ -224,7 +253,7 @@ sub calculate_average {
'problem.state' => [ FixMyStreet::DB::Result::Problem->visible_states() ],
}, {
select => [
- { extract => "epoch from me.confirmed-problem.confirmed", -as => 'time' },
+ { extract => \"epoch from me.confirmed-problem.confirmed", -as => 'time' },
],
as => [ qw/time/ ],
rows => 100,
diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm
index 5d0253ef4..b217bf96c 100644
--- a/perllib/FixMyStreet/DB/Result/Comment.pm
+++ b/perllib/FixMyStreet/DB/Result/Comment.pm
@@ -101,6 +101,7 @@ __PACKAGE__->load_components("+FixMyStreet::DB::RABXColumn");
__PACKAGE__->rabx_column('extra');
use Moo;
+use FixMyStreet::Template::SafeString;
use namespace::clean -except => [ 'meta' ];
use FixMyStreet::Template;
@@ -201,7 +202,7 @@ sub moderation_filter {
=head2 meta_line
Returns a string to be used on a report update, describing some of the metadata
-about an update
+about an update. Can include HTML.
=cut
@@ -225,10 +226,15 @@ sub meta_line {
} else {
$body = $self->user->body;
}
+ $body = FixMyStreet::Template::html_filter($body);
if ($body eq 'Bromley Council') {
$body = "$body <img src='/cobrands/bromley/favicon.png' alt=''>";
} elsif ($body eq 'Royal Borough of Greenwich') {
$body = "$body <img src='/cobrands/greenwich/favicon.png' alt=''>";
+ } elsif ($body eq 'Hounslow Borough Council') {
+ $body = 'Hounslow Highways';
+ } elsif ($body eq 'Isle of Wight Council') {
+ $body = 'Island Roads';
}
}
my $cobrand_always_view_body_user = $c->cobrand->call_hook("always_view_body_contribute_details");
@@ -255,7 +261,7 @@ sub meta_line {
$meta .= ', ' . _( 'and a defect raised' );
}
- return $meta;
+ return FixMyStreet::Template::SafeString->new($meta);
};
sub problem_state_processed {
@@ -272,7 +278,11 @@ sub problem_state_display {
return '' unless $state;
my $cobrand_name = $c->cobrand->moniker;
- $cobrand_name = 'bromley' if $self->problem->to_body_named('Bromley');
+ my $names = join(',,', @{$self->problem->body_names});
+ if ($names =~ /(Bromley|Isle of Wight|TfL)/) {
+ ($cobrand_name = lc $1) =~ s/ //g;
+ }
+
return FixMyStreet::DB->resultset("State")->display($state, 1, $cobrand_name);
}
@@ -282,6 +292,7 @@ sub is_latest {
{ problem_id => $self->problem_id, state => 'confirmed' },
{ order_by => [ { -desc => 'confirmed' }, { -desc => 'id' } ] }
)->first;
+ return unless $latest_update;
return $latest_update->id == $self->id;
}
diff --git a/perllib/FixMyStreet/DB/Result/Contact.pm b/perllib/FixMyStreet/DB/Result/Contact.pm
index 17620f279..affc6d480 100644
--- a/perllib/FixMyStreet/DB/Result/Contact.pm
+++ b/perllib/FixMyStreet/DB/Result/Contact.pm
@@ -93,12 +93,34 @@ __PACKAGE__->many_to_many( response_templates => 'contact_response_templates', '
__PACKAGE__->many_to_many( response_priorities => 'contact_response_priorities', 'response_priority' );
__PACKAGE__->many_to_many( defect_types => 'contact_defect_types', 'defect_type' );
+__PACKAGE__->might_have(
+ "translations",
+ "FixMyStreet::DB::Result::Translation",
+ sub {
+ my $args = shift;
+ return {
+ "$args->{foreign_alias}.object_id" => { -ident => "$args->{self_alias}.id" },
+ "$args->{foreign_alias}.tbl" => { '=' => \"?" },
+ "$args->{foreign_alias}.col" => { '=' => \"?" },
+ "$args->{foreign_alias}.lang" => { '=' => \"?" },
+ };
+ },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+
sub category_display {
my $self = shift;
- $self->translate_column('category');
+ $self->get_extra_metadata('display_name') || $self->translate_column('category');
}
-sub get_metadata_for_editing {
+sub groups {
+ my $self = shift;
+ my $groups = $self->get_extra_metadata('group') || [];
+ $groups = [ $groups ] unless ref $groups eq 'ARRAY';
+ return $groups;
+}
+
+sub get_all_metadata {
my $self = shift;
my @metadata = @{$self->get_extra_fields};
@@ -111,9 +133,19 @@ sub get_metadata_for_editing {
return \@metadata;
}
+sub get_metadata_for_editing {
+ my $self = shift;
+ my $metadata = $self->get_all_metadata;
+
+ # Ignore the special admin-form-created entry
+ my @metadata = grep { $_->{code} ne '_fms_disable_' } @$metadata;
+
+ return \@metadata;
+}
+
sub get_metadata_for_input {
my $self = shift;
- my $metadata = $self->get_metadata_for_editing;
+ my $metadata = $self->get_all_metadata;
# Also ignore any we have with a 'server_set' automated attribute
my @metadata = grep { !$_->{automated} || $_->{automated} ne 'server_set' } @$metadata;
@@ -121,9 +153,26 @@ sub get_metadata_for_input {
return \@metadata;
}
+sub get_metadata_for_storage {
+ my $self = shift;
+ my $metadata = $self->get_metadata_for_input;
+
+ # Also ignore any that were only for textual display
+ my @metadata = grep { ($_->{variable} || '') ne 'false' } @$metadata;
+
+ return \@metadata;
+}
+
sub id_field {
my $self = shift;
return $self->get_extra_metadata('id_field') || 'fixmystreet_id';
}
+sub disable_form_field {
+ my $self = shift;
+ my $metadata = $self->get_all_metadata;
+ my ($field) = grep { $_->{code} eq '_fms_disable_' } @$metadata;
+ return $field;
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/Result/ManifestTheme.pm b/perllib/FixMyStreet/DB/Result/ManifestTheme.pm
new file mode 100644
index 000000000..a2f49eacb
--- /dev/null
+++ b/perllib/FixMyStreet/DB/Result/ManifestTheme.pm
@@ -0,0 +1,47 @@
+use utf8;
+package FixMyStreet::DB::Result::ManifestTheme;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+__PACKAGE__->load_components(
+ "FilterColumn",
+ "FixMyStreet::InflateColumn::DateTime",
+ "FixMyStreet::EncodedColumn",
+);
+__PACKAGE__->table("manifest_theme");
+__PACKAGE__->add_columns(
+ "id",
+ {
+ data_type => "integer",
+ is_auto_increment => 1,
+ is_nullable => 0,
+ sequence => "manifest_theme_id_seq",
+ },
+ "cobrand",
+ { data_type => "text", is_nullable => 0 },
+ "name",
+ { data_type => "text", is_nullable => 0 },
+ "short_name",
+ { data_type => "text", is_nullable => 0 },
+ "background_colour",
+ { data_type => "text", is_nullable => 1 },
+ "theme_colour",
+ { data_type => "text", is_nullable => 1 },
+ "images",
+ { data_type => "text[]", is_nullable => 1 },
+);
+__PACKAGE__->set_primary_key("id");
+__PACKAGE__->add_unique_constraint("manifest_theme_cobrand_key", ["cobrand"]);
+
+
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2020-01-30 14:30:42
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Sgbva7nEVkjqG/+lQL/ryw
+
+
+# You can replace this text with custom code or comments, and it will be preserved on regeneration
+1;
diff --git a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm
index 18d2a7683..1805e1fd2 100644
--- a/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm
+++ b/perllib/FixMyStreet/DB/Result/ModerationOriginalData.pm
@@ -74,6 +74,7 @@ __PACKAGE__->belongs_to(
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:FLKiZELcfBcc9VwHU2MZYQ
use Moo;
+use FixMyStreet::Template::SafeString;
use Text::Diff;
use Data::Dumper;
@@ -147,11 +148,12 @@ sub compare_photo {
push @deleted, $diff->Items(1);
push @added, $diff->Items(2);
}
- return (join ', ', map {
+ my $s = (join ', ', map {
"<del style='background-color:#fcc'>$_</del>";
} @deleted) . (join ', ', map {
"<ins style='background-color:#cfc'>$_</ins>";
} @added);
+ return FixMyStreet::Template::SafeString->new($s);
}
sub compare_extra {
@@ -212,7 +214,7 @@ sub string_diff {
$string .= $inserted;
}
}
- return $string;
+ return FixMyStreet::Template::SafeString->new($string);
}
1;
diff --git a/perllib/FixMyStreet/DB/Result/Problem.pm b/perllib/FixMyStreet/DB/Result/Problem.pm
index dc45091ee..37563d327 100644
--- a/perllib/FixMyStreet/DB/Result/Problem.pm
+++ b/perllib/FixMyStreet/DB/Result/Problem.pm
@@ -201,6 +201,8 @@ use Moo;
use namespace::clean -except => [ 'meta' ];
use Utils;
use FixMyStreet::Map::FMS;
+use FixMyStreet::Template;
+use FixMyStreet::Template::SafeString;
use LWP::Simple qw($ua);
use RABX;
use URI;
@@ -338,6 +340,7 @@ around service => sub {
sub title_safe {
my $self = shift;
return _('Awaiting moderation') if $self->cobrand eq 'zurich' && $self->state eq 'submitted';
+ return sprintf("%s problem", $self->category) if $self->cobrand eq 'tfl' && $self->result_source->schema->cobrand->moniker ne 'tfl';
return $self->title;
}
@@ -362,6 +365,9 @@ sub check_for_errors {
$errors{title} = _('Please enter a subject')
unless $self->title =~ m/\S/;
+ $errors{title} = _('Please make sure you are not including an email address')
+ if mySociety::EmailUtil::is_valid_email($self->title);
+
$errors{detail} = _('Please enter some details')
unless $self->detail =~ m/\S/;
@@ -373,13 +379,6 @@ sub check_for_errors {
$errors{name} = _('Please enter your name');
}
- if ( $self->category
- && $self->category eq _('-- Pick a category --') )
- {
- $errors{category} = _('Please choose a category');
- $self->category(undef);
- }
-
return \%errors;
}
@@ -408,7 +407,28 @@ sub confirm {
sub category_display {
my $self = shift;
- $self->translate_column('category');
+ my $contact = $self->category_row;
+ return $self->category unless $contact; # Fallback; shouldn't happen, but some tests
+ return $contact->category_display;
+}
+
+=head2 category_row
+
+Returns the corresponding Contact object for this problem's category and body.
+If the report was sent to multiple bodies, only returns the first.
+
+=cut
+
+sub category_row {
+ my $self = shift;
+ my $schema = $self->result_source->schema;
+ my $body_id = $self->bodies_str_ids->[0];
+ return unless $body_id && $body_id =~ /^[0-9]+$/;
+ my $contact = $schema->resultset("Contact")->find({
+ body_id => $body_id,
+ category => $self->category,
+ });
+ return $contact;
}
sub bodies_str_ids {
@@ -505,6 +525,31 @@ sub tokenised_url {
return "/M/". $token->token;
}
+has view_token => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my $token = FixMyStreet::DB->resultset('Token')->create({
+ scope => 'alert_to_reporter',
+ data => { id => $self->id }
+ });
+ },
+);
+
+=head2 view_url
+
+Return a url for this problem report that will always show it
+(even if e.g. a private report) but does not log the user in.
+
+=cut
+
+sub view_url {
+ my $self = shift;
+ return $self->url unless $self->non_public;
+ return "/R/" . $self->view_token->token;
+}
+
=head2 is_hidden
Returns 1 if the problem is in an hidden state otherwise 0.
@@ -652,16 +697,16 @@ sub body {
my $cache = $problem->result_source->schema->cache;
return $cache->{bodies}{$problem->external_body} //= $c->model('DB::Body')->find({ id => $problem->external_body });
} else {
- $body = $problem->external_body;
+ $body = FixMyStreet::Template::html_filter($problem->external_body);
}
} else {
my $bodies = $problem->bodies;
my @body_names = sort map {
my $name = $_->name;
if ($c and FixMyStreet->config('AREA_LINKS_FROM_PROBLEMS')) {
- '<a href="' . $_->url . '">' . $name . '</a>';
+ '<a href="' . $_->url . '">' . FixMyStreet::Template::html_filter($name) . '</a>';
} else {
- $name;
+ FixMyStreet::Template::html_filter($name);
}
} values %$bodies;
if ( scalar @body_names > 2 ) {
@@ -671,7 +716,7 @@ sub body {
$body = join( _(' and '), @body_names);
}
}
- return $body;
+ return FixMyStreet::Template::SafeString->new($body);
}
@@ -755,23 +800,26 @@ sub defect_types {
# Note: this only makes sense when called on a problem that has been sent!
sub can_display_external_id {
my $self = shift;
- if ($self->external_id && $self->send_method_used && $self->to_body_named('Oxfordshire|Lincolnshire')) {
+ if ($self->external_id && $self->to_body_named('Oxfordshire|Lincolnshire|Isle of Wight|East Sussex')) {
return 1;
}
return 0;
}
+# This can return HTML and is safe, so returns a FixMyStreet::Template::SafeString
sub duration_string {
my ( $problem, $c ) = @_;
my $body = $c->cobrand->call_hook(link_to_council_cobrand => $problem) || $problem->body($c);
my $handler = $c->cobrand->call_hook(get_body_handler_for_problem => $problem);
if ( $handler && $handler->call_hook('is_council_with_case_management') ) {
- return sprintf(_('Received by %s moments later'), $body);
+ my $s = sprintf(_('Received by %s moments later'), $body);
+ return FixMyStreet::Template::SafeString->new($s);
}
return unless $problem->whensent;
- return sprintf(_('Sent to %s %s later'), $body,
+ my $s = sprintf(_('Sent to %s %s later'), $body,
Utils::prettify_duration($problem->whensent->epoch - $problem->confirmed->epoch, 'minute')
);
+ return FixMyStreet::Template::SafeString->new($s);
}
sub local_coords {
@@ -889,6 +937,8 @@ bodies by some mechanism. Right now that mechanism is Open311.
sub updates_sent_to_body {
my $self = shift;
+
+ return 1 if $self->to_body_named('TfL');
return unless $self->send_method_used && $self->send_method_used =~ /Open311/;
# Some bodies only send updates *to* FMS, they don't receive updates.
@@ -1015,11 +1065,12 @@ sub pin_data {
problem => $self,
draggable => $opts{draggable},
type => $opts{type},
+ base_url => $c->cobrand->relative_url_for_report($self),
}
};
sub static_map {
- my ($self) = @_;
+ my ($self, %params) = @_;
return unless $IM;
@@ -1027,7 +1078,11 @@ sub static_map {
unless $FixMyStreet::Map::map_class->isa("FixMyStreet::Map::OSM");
my $map_data = $FixMyStreet::Map::map_class->generate_map_data(
- { cobrand => $self->get_cobrand_logged },
+ {
+ cobrand => $self->get_cobrand_logged,
+ distance => 1, # prevents the call to Gaze which isn't necessary
+ $params{zoom} ? ( zoom => $params{zoom} ) : (),
+ },
latitude => $self->latitude,
longitude => $self->longitude,
pins => $self->used_map
@@ -1084,7 +1139,7 @@ sub static_map {
$image->Extent( geometry => '512x384', gravity => 'NorthWest');
$image->Extent( geometry => '512x320', gravity => 'SouthWest');
- $image->Scale( geometry => "310x200>" );
+ $image->Scale( geometry => "310x200>" ) unless $params{full_size};
my @blobs = $image->ImageToBlob(magick => 'jpeg');
undef $image;
@@ -1161,4 +1216,15 @@ has inspection_log_entry => (
},
);
+has alerts => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ return $self->result_source->schema->resultset('Alert')->search({
+ alert_type => 'new_updates', parameter => $self->id
+ });
+ },
+);
+
1;
diff --git a/perllib/FixMyStreet/DB/Result/Role.pm b/perllib/FixMyStreet/DB/Result/Role.pm
new file mode 100644
index 000000000..e35b0b195
--- /dev/null
+++ b/perllib/FixMyStreet/DB/Result/Role.pm
@@ -0,0 +1,53 @@
+use utf8;
+package FixMyStreet::DB::Result::Role;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+__PACKAGE__->load_components(
+ "FilterColumn",
+ "FixMyStreet::InflateColumn::DateTime",
+ "FixMyStreet::EncodedColumn",
+);
+__PACKAGE__->table("roles");
+__PACKAGE__->add_columns(
+ "id",
+ {
+ data_type => "integer",
+ is_auto_increment => 1,
+ is_nullable => 0,
+ sequence => "roles_id_seq",
+ },
+ "body_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+ "name",
+ { data_type => "text", is_nullable => 1 },
+ "permissions",
+ { data_type => "text[]", is_nullable => 1 },
+);
+__PACKAGE__->set_primary_key("id");
+__PACKAGE__->add_unique_constraint("roles_body_id_name_key", ["body_id", "name"]);
+__PACKAGE__->belongs_to(
+ "body",
+ "FixMyStreet::DB::Result::Body",
+ { id => "body_id" },
+ { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" },
+);
+__PACKAGE__->has_many(
+ "user_roles",
+ "FixMyStreet::DB::Result::UserRole",
+ { "foreign.role_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
+
+
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:KkzVQZuzExH8PhZLJsnZgg
+
+__PACKAGE__->many_to_many( users => 'user_roles', 'user' );
+
+1;
diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm
index d01ba92d0..b0a05d0b7 100644
--- a/perllib/FixMyStreet/DB/Result/User.pm
+++ b/perllib/FixMyStreet/DB/Result/User.pm
@@ -24,22 +24,30 @@ __PACKAGE__->add_columns(
},
"email",
{ data_type => "text", is_nullable => 1 },
- "email_verified",
- { data_type => "boolean", default_value => \"false", is_nullable => 0 },
"name",
{ data_type => "text", is_nullable => 1 },
"phone",
{ data_type => "text", is_nullable => 1 },
- "phone_verified",
- { data_type => "boolean", default_value => \"false", is_nullable => 0 },
"password",
{ data_type => "text", default_value => "", is_nullable => 0 },
- "from_body",
- { data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
"flagged",
{ data_type => "boolean", default_value => \"false", is_nullable => 0 },
+ "from_body",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 1 },
+ "title",
+ { data_type => "text", is_nullable => 1 },
+ "facebook_id",
+ { data_type => "bigint", is_nullable => 1 },
+ "twitter_id",
+ { data_type => "bigint", is_nullable => 1 },
"is_superuser",
{ data_type => "boolean", default_value => \"false", is_nullable => 0 },
+ "extra",
+ { data_type => "text", is_nullable => 1 },
+ "email_verified",
+ { data_type => "boolean", default_value => \"false", is_nullable => 0 },
+ "phone_verified",
+ { data_type => "boolean", default_value => \"false", is_nullable => 0 },
"created",
{
data_type => "timestamp",
@@ -54,16 +62,10 @@ __PACKAGE__->add_columns(
is_nullable => 0,
original => { default_value => \"now()" },
},
- "title",
- { data_type => "text", is_nullable => 1 },
- "twitter_id",
- { data_type => "bigint", is_nullable => 1 },
- "facebook_id",
- { data_type => "bigint", is_nullable => 1 },
- "extra",
- { data_type => "text", is_nullable => 1 },
"area_ids",
{ data_type => "integer[]", is_nullable => 1 },
+ "oidc_ids",
+ { data_type => "text[]", is_nullable => 1 },
);
__PACKAGE__->set_primary_key("id");
__PACKAGE__->add_unique_constraint("users_facebook_id_key", ["facebook_id"]);
@@ -121,10 +123,16 @@ __PACKAGE__->has_many(
{ "foreign.user_id" => "self.id" },
{ cascade_copy => 0, cascade_delete => 0 },
);
+__PACKAGE__->has_many(
+ "user_roles",
+ "FixMyStreet::DB::Result::UserRole",
+ { "foreign.user_id" => "self.id" },
+ { cascade_copy => 0, cascade_delete => 0 },
+);
-# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-04-25 12:06:39
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:BCCqv3JCec8psuRk/SdCJQ
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-06-20 16:31:44
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Ryb6giJm/7N7svg/d+2GeA
# These are not fully unique constraints (they only are when the *_verified
# is true), but this is managed in ResultSet::User's find() wrapper.
@@ -136,6 +144,7 @@ __PACKAGE__->rabx_column('extra');
use Moo;
use Text::CSV;
+use List::MoreUtils 'uniq';
use FixMyStreet::SMS;
use mySociety::EmailUtil;
use namespace::clean -except => [ 'meta' ];
@@ -143,6 +152,7 @@ use namespace::clean -except => [ 'meta' ];
with 'FixMyStreet::Roles::Extra';
__PACKAGE__->many_to_many( planned_reports => 'user_planned_reports', 'report' );
+__PACKAGE__->many_to_many( roles => 'user_roles', 'role' );
sub cost {
FixMyStreet->test_mode ? 1 : 12;
@@ -153,10 +163,30 @@ __PACKAGE__->add_columns(
encode_column => 1,
encode_class => 'Crypt::Eksblowfish::Bcrypt',
encode_args => { cost => cost() },
- encode_check_method => 'check_password',
+ encode_check_method => '_check_password',
},
);
+sub check_password {
+ my $self = shift;
+ my $cobrand = $self->result_source->schema->cobrand;
+ if ($cobrand->moniker eq 'tfl') {
+ my $col_v = $self->get_extra_metadata('tfl_password');
+ return unless defined $col_v;
+ $self->_column_encoders->{password}->($_[0], $col_v) eq $col_v;
+ } else {
+ $self->_check_password(@_);
+ }
+}
+
+around password => sub {
+ my ($orig, $self) = (shift, shift);
+ if (@_) {
+ $self->set_extra_metadata(last_password_change => time());
+ }
+ $self->$orig(@_);
+};
+
=head2 username
Returns a verified email or phone for this user, preferring email,
@@ -188,6 +218,15 @@ sub latest_anonymity {
return $obj ? $obj->anonymous : 0;
}
+sub latest_visible_problem {
+ my $self = shift;
+ return $self->problems->search({
+ state => [ FixMyStreet::DB::Result::Problem->visible_states() ]
+ }, {
+ order_by => { -desc => 'id' }
+ })->single;
+}
+
=head2 check_for_errors
$error_hashref = $user->check_for_errors();
@@ -298,7 +337,11 @@ sub body {
sub moderating_user_name {
my $self = shift;
- return $self->body || _('an administrator');
+ my $body = $self->body;
+ if ( $body && $body eq 'Isle of Wight Council' ) {
+ $body = 'Island Roads';
+ }
+ return $body || _('an administrator');
}
=head2 belongs_to_body
@@ -375,7 +418,18 @@ has body_permissions => (
lazy => 1,
default => sub {
my $self = shift;
- return [ $self->user_body_permissions->all ];
+ my $perms = [];
+ foreach my $role ($self->roles->all) {
+ push @$perms, map { {
+ body_id => $role->body_id,
+ permission => $_,
+ } } @{$role->permissions};
+ }
+ push @$perms, map { {
+ body_id => $_->body_id,
+ permission => $_->permission_type,
+ } } $self->user_body_permissions->all;
+ return $perms;
},
);
@@ -392,8 +446,8 @@ sub permissions {
return unless $self->belongs_to_body($body_id);
- my @permissions = grep { $_->body_id == $self->from_body->id } @{$self->body_permissions};
- return { map { $_->permission_type => 1 } @permissions };
+ my @permissions = grep { $_->{body_id} == $self->from_body->id } @{$self->body_permissions};
+ return { map { $_->{permission} => 1 } @permissions };
}
sub has_permission_to {
@@ -404,18 +458,15 @@ sub has_permission_to {
my $cobrand = $self->result_source->schema->cobrand;
my $cobrand_perms = $cobrand->available_permissions;
my %available = map { %$_ } values %$cobrand_perms;
- # The 'trusted' permission is never set in the cobrand's
- # available_permissions (see note there in Default.pm) so include it here.
- $available{trusted} = 1;
return 0 unless $available{$permission_type};
return 1 if $self->is_superuser;
- return 0 if !$body_ids || (ref $body_ids && !@$body_ids);
- $body_ids = [ $body_ids ] unless ref $body_ids;
+ return 0 if !$body_ids || (ref $body_ids eq 'ARRAY' && !@$body_ids);
+ $body_ids = [ $body_ids ] unless ref $body_ids eq 'ARRAY';
my %body_ids = map { $_ => 1 } @$body_ids;
foreach (@{$self->body_permissions}) {
- return 1 if $_->permission_type eq $permission_type && $body_ids{$_->body_id};
+ return 1 if $_->{permission} eq $permission_type && $body_ids{$_->{body_id}};
}
return 0;
}
@@ -464,7 +515,7 @@ sub admin_user_body_permissions {
sub has_2fa {
my $self = shift;
- return $self->is_superuser && $self->get_extra_metadata('2fa_secret');
+ return $self->get_extra_metadata('2fa_secret');
}
sub contributing_as {
@@ -516,6 +567,7 @@ sub anonymize_account {
title => undef,
twitter_id => undef,
facebook_id => undef,
+ oidc_ids => undef,
});
}
@@ -565,14 +617,6 @@ sub is_planned_report {
return scalar grep { $_->report_id == $id } @{$self->active_user_planned_reports};
}
-sub update_reputation {
- my ( $self, $change ) = @_;
-
- my $reputation = $self->get_extra_metadata('reputation') || 0;
- $self->set_extra_metadata( reputation => $reputation + $change);
- $self->update;
-}
-
has categories => (
is => 'ro',
lazy => 1,
@@ -621,4 +665,35 @@ sub in_area {
return $self->areas_hash->{$area};
}
+has roles_hash => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ my %ids = map { $_->role_id => 1 } $self->user_roles->all;
+ return \%ids;
+ },
+);
+
+sub in_role {
+ my ($self, $role) = @_;
+ return $self->roles_hash->{$role};
+}
+
+sub add_oidc_id {
+ my ($self, $oidc_id) = @_;
+
+ my $oidc_ids = $self->oidc_ids || [];
+ my @oidc_ids = uniq ( $oidc_id, @$oidc_ids );
+ $self->oidc_ids(\@oidc_ids);
+}
+
+sub remove_oidc_id {
+ my ($self, $oidc_id) = @_;
+
+ my $oidc_ids = $self->oidc_ids || [];
+ my @oidc_ids = grep { $_ ne $oidc_id } @$oidc_ids;
+ $self->oidc_ids(scalar @oidc_ids ? \@oidc_ids : undef);
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/Result/UserRole.pm b/perllib/FixMyStreet/DB/Result/UserRole.pm
new file mode 100644
index 000000000..9186e2aa1
--- /dev/null
+++ b/perllib/FixMyStreet/DB/Result/UserRole.pm
@@ -0,0 +1,50 @@
+use utf8;
+package FixMyStreet::DB::Result::UserRole;
+
+# Created by DBIx::Class::Schema::Loader
+# DO NOT MODIFY THE FIRST PART OF THIS FILE
+
+use strict;
+use warnings;
+
+use base 'DBIx::Class::Core';
+__PACKAGE__->load_components(
+ "FilterColumn",
+ "FixMyStreet::InflateColumn::DateTime",
+ "FixMyStreet::EncodedColumn",
+);
+__PACKAGE__->table("user_roles");
+__PACKAGE__->add_columns(
+ "id",
+ {
+ data_type => "integer",
+ is_auto_increment => 1,
+ is_nullable => 0,
+ sequence => "user_roles_id_seq",
+ },
+ "role_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+ "user_id",
+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 },
+);
+__PACKAGE__->set_primary_key("id");
+__PACKAGE__->belongs_to(
+ "role",
+ "FixMyStreet::DB::Result::Role",
+ { id => "role_id" },
+ { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" },
+);
+__PACKAGE__->belongs_to(
+ "user",
+ "FixMyStreet::DB::Result::User",
+ { id => "user_id" },
+ { is_deferrable => 0, on_delete => "CASCADE,", on_update => "NO ACTION" },
+);
+
+
+# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 16:52:59
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:1BSR4j0o5PApKEZmzVAnLg
+
+
+# You can replace this text with custom code or comments, and it will be preserved on regeneration
+1;
diff --git a/perllib/FixMyStreet/DB/ResultSet/Alert.pm b/perllib/FixMyStreet/DB/ResultSet/Alert.pm
index c61053fff..ddf80bc52 100644
--- a/perllib/FixMyStreet/DB/ResultSet/Alert.pm
+++ b/perllib/FixMyStreet/DB/ResultSet/Alert.pm
@@ -7,11 +7,6 @@ use warnings;
sub timeline_created {
my ( $rs, $restriction ) = @_;
- my $prefetch =
- $rs->result_source->storage->sql_maker->quote_char ?
- [ qw/alert_type user/ ] :
- [ qw/alert_type/ ];
-
return $rs->search(
{
whensubscribed => { '>=', \"current_timestamp-'7 days'::interval" },
@@ -19,7 +14,7 @@ sub timeline_created {
%{ $restriction },
},
{
- prefetch => $prefetch,
+ prefetch => [ qw/alert_type user/ ],
}
);
}
diff --git a/perllib/FixMyStreet/DB/ResultSet/Comment.pm b/perllib/FixMyStreet/DB/ResultSet/Comment.pm
index b9a3df62d..034b86a40 100644
--- a/perllib/FixMyStreet/DB/ResultSet/Comment.pm
+++ b/perllib/FixMyStreet/DB/ResultSet/Comment.pm
@@ -13,18 +13,13 @@ sub to_body {
sub timeline {
my ( $rs ) = @_;
- my $prefetch =
- $rs->result_source->storage->sql_maker->quote_char ?
- [ qw/user/ ] :
- [];
-
return $rs->search(
{
'me.state' => 'confirmed',
'me.created' => { '>=', \"current_timestamp-'7 days'::interval" },
},
{
- prefetch => $prefetch,
+ prefetch => 'user',
}
);
}
diff --git a/perllib/FixMyStreet/DB/ResultSet/Contact.pm b/perllib/FixMyStreet/DB/ResultSet/Contact.pm
index 8ef6d1ac5..801d20cc0 100644
--- a/perllib/FixMyStreet/DB/ResultSet/Contact.pm
+++ b/perllib/FixMyStreet/DB/ResultSet/Contact.pm
@@ -3,6 +3,7 @@ use base 'DBIx::Class::ResultSet';
use strict;
use warnings;
+use POSIX qw(strcoll);
sub me { join('.', shift->current_source_alias, shift || q{}) }
@@ -16,7 +17,12 @@ Filter down to not deleted contacts (so active or inactive).
sub not_deleted {
my $rs = shift;
- return $rs->search( { $rs->me('state') => { '!=' => 'deleted' } } );
+ return $rs->search( { $rs->me('state') => { -not_in => [ 'deleted', 'staff' ] } } );
+}
+
+sub not_deleted_admin {
+ my $rs = shift;
+ return $rs->search( { $rs->me('state') => { -not_in => [ 'deleted' ] } } );
}
sub active {
@@ -24,6 +30,53 @@ sub active {
$rs->search( { $rs->me('state') => [ 'unconfirmed', 'confirmed' ] } );
}
+sub for_new_reports {
+ my ($rs, $c, $bodies) = @_;
+ my $params = {
+ $rs->me('body_id') => [ keys %$bodies ],
+ };
+
+ if ($c->user_exists && $c->user->is_superuser) {
+ # Everything normal OR any staff states
+ $params->{$rs->me('state')} = [ 'unconfirmed', 'confirmed', 'staff' ];
+ } elsif ($c->user_exists && $c->user->from_body) {
+ # Everything normal OR staff state in the user body
+ $params->{'-or'} = [
+ $rs->me('state') => [ 'unconfirmed', 'confirmed' ],
+ {
+ $rs->me('body_id') => $c->user->from_body->id,
+ $rs->me('state') => 'staff',
+ },
+ ];
+ } else {
+ $params->{$rs->me('state')} = [ 'unconfirmed', 'confirmed' ];
+ }
+
+ $rs->search($params, { prefetch => 'body' });
+}
+
+sub translated {
+ my $rs = shift;
+ my $schema = $rs->result_source->schema;
+ $rs->search(undef, {
+ '+columns' => { 'msgstr' => 'translations.msgstr' },
+ join => 'translations',
+ bind => [ 'category', $schema->lang, 'contact' ],
+ });
+}
+
+sub all_sorted {
+ my $rs = shift;
+
+ my @contacts = $rs->translated->all;
+ @contacts = sort {
+ my $a_name = $a->get_extra_metadata('display_name') || $a->get_column('msgstr') || $a->category;
+ my $b_name = $b->get_extra_metadata('display_name') || $b->get_column('msgstr') || $b->category;
+ strcoll($a_name, $b_name)
+ } @contacts;
+ return @contacts;
+}
+
sub summary_count {
my ( $rs, $restriction ) = @_;
@@ -37,4 +90,13 @@ sub summary_count {
);
}
+sub group_lookup {
+ my $rs = shift;
+ map {
+ my $group = $_->get_extra_metadata('group') || '';
+ $group = join(',', ref $group ? @$group : $group);
+ $_->category => $group
+ } $rs->all;
+}
+
1;
diff --git a/perllib/FixMyStreet/DB/ResultSet/Problem.pm b/perllib/FixMyStreet/DB/ResultSet/Problem.pm
index 37fc34057..e23cf78e1 100644
--- a/perllib/FixMyStreet/DB/ResultSet/Problem.pm
+++ b/perllib/FixMyStreet/DB/ResultSet/Problem.pm
@@ -141,7 +141,7 @@ sub _recent {
$query->{photo} = { '!=', undef } if $photos;
my $attrs = {
- order_by => { -desc => 'coalesce(confirmed, created)' },
+ order_by => { -desc => \'coalesce(confirmed, created)' },
rows => $num,
};
@@ -155,10 +155,11 @@ sub _recent {
} else {
$probs = Memcached::get($key);
if ($probs) {
- # Need to reattach schema so that confirmed column gets reinflated.
- $probs->[0]->result_source->schema( $rs->result_source->schema ) if $probs->[0];
- # Catch any cached ones since hidden
- $probs = [ grep { $_->photo && ! $_->is_hidden } @$probs ];
+ # Need to refetch to check if hidden since cached
+ $probs = [ $rs->search({
+ id => [ map { $_->id } @$probs ],
+ %$query,
+ }, $attrs)->all ];
} else {
$probs = [ $rs->search( $query, $attrs )->all ];
Memcached::set($key, $probs, _cache_timeout());
@@ -207,11 +208,6 @@ sub around_map {
sub timeline {
my ( $rs ) = @_;
- my $prefetch =
- $rs->result_source->storage->sql_maker->quote_char ?
- [ qw/user/ ] :
- [];
-
return $rs->search(
{
-or => {
@@ -221,7 +217,7 @@ sub timeline {
}
},
{
- prefetch => $prefetch,
+ prefetch => 'user',
}
);
}
@@ -245,12 +241,9 @@ sub unique_users {
return $rs->search( {
state => [ FixMyStreet::DB::Result::Problem->visible_states() ],
}, {
- select => [ { distinct => 'user_id' } ],
- as => [ 'user_id' ]
- } )->as_subselect_rs->search( undef, {
- select => [ { count => 'user_id' } ],
- as => [ 'count' ]
- } )->first->get_column('count');
+ columns => [ 'user_id' ],
+ distinct => 1,
+ } );
}
sub categories_summary {
@@ -273,7 +266,9 @@ sub categories_summary {
sub include_comment_counts {
my $rs = shift;
my $order_by = $rs->{attrs}{order_by};
- return $rs unless ref $order_by eq 'HASH' && $order_by->{-desc} eq 'comment_count';
+ return $rs unless
+ (ref $order_by eq 'ARRAY' && ref $order_by->[0] eq 'HASH' && $order_by->[0]->{-desc} eq 'comment_count')
+ || (ref $order_by eq 'HASH' && $order_by->{-desc} eq 'comment_count');
$rs->search({}, {
'+select' => [ {
"" => \'(select count(*) from comment where problem_id=me.id and state=\'confirmed\')',
diff --git a/perllib/FixMyStreet/DB/ResultSet/State.pm b/perllib/FixMyStreet/DB/ResultSet/State.pm
index 3e6169aeb..4f98efbf2 100644
--- a/perllib/FixMyStreet/DB/ResultSet/State.pm
+++ b/perllib/FixMyStreet/DB/ResultSet/State.pm
@@ -1,6 +1,7 @@
package FixMyStreet::DB::ResultSet::State;
use base 'DBIx::Class::ResultSet';
+use utf8;
use strict;
use warnings;
use Memcached;
@@ -74,8 +75,13 @@ sub display {
return $unchanging->{$label} if $unchanging->{$label};
if ($cobrand && $label eq 'not responsible') {
return 'third party responsibility' if $cobrand eq 'bromley';
+ return "not Island Roads’ responsibility" if $cobrand eq 'isleofwight';
+ return "not TfL’s responsibility" if $cobrand eq 'tfl';
return _("not the council's responsibility");
}
+ if ($cobrand && $cobrand eq 'oxfordshire' && $label eq 'unable to fix') {
+ return 'Investigation complete';
+ }
my ($state) = $rs->_filter(sub { $_->label eq $label });
return $label unless $state;
$state->name($translate_now->{$label}) if $translate_now->{$label};
diff --git a/perllib/FixMyStreet/DB/Schema.pm b/perllib/FixMyStreet/DB/Schema.pm
index be39069d8..e39a8422e 100644
--- a/perllib/FixMyStreet/DB/Schema.pm
+++ b/perllib/FixMyStreet/DB/Schema.pm
@@ -21,6 +21,7 @@ __PACKAGE__->load_namespaces(
use Moo;
use FixMyStreet;
+__PACKAGE__->storage_type('::DBI::PgServerCursor');
__PACKAGE__->connection(FixMyStreet->dbic_connect_info);
has lang => ( is => 'rw' );
diff --git a/perllib/FixMyStreet/Email.pm b/perllib/FixMyStreet/Email.pm
index 2b72b5c63..3d7b48539 100644
--- a/perllib/FixMyStreet/Email.pm
+++ b/perllib/FixMyStreet/Email.pm
@@ -152,7 +152,7 @@ sub find_template_dir {
sub send_cron {
my ( $schema, $template, $vars, $hdrs, $env_from, $nomail, $cobrand, $lang_code ) = @_;
- my $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL');
+ my $sender = $cobrand->do_not_reply_email;
$env_from ||= $sender;
if (!$hdrs->{From}) {
my $sender_name = $cobrand->contact_name;
@@ -169,15 +169,20 @@ sub send_cron {
push @include_path, FixMyStreet->path_to( 'templates', 'email', 'default' );
my $tt = FixMyStreet::Template->new({
INCLUDE_PATH => \@include_path,
+ disable_autoescape => 1,
});
$vars->{signature} = _render_template($tt, 'signature.txt', $vars);
$vars->{site_name} = Utils::trim_text(_render_template($tt, 'site-name.txt', $vars));
+ $vars->{staging} = FixMyStreet->config('STAGING_SITE');
$hdrs->{_body_} = _render_template($tt, $template, $vars);
if ($html_template) {
my @inline_images;
$vars->{inline_image} = sub { add_inline_image(\@inline_images, @_) };
$vars->{file_exists} = sub { -e FixMyStreet->path_to(@_) };
+ my $tt = FixMyStreet::Template->new({
+ INCLUDE_PATH => \@include_path,
+ });
$hdrs->{_html_} = _render_template($tt, $html_template, $vars);
$hdrs->{_html_images_} = \@inline_images;
}
@@ -357,8 +362,9 @@ sub construct_email ($) {
}
}
- if ($p->{_attachments_}) {
+ if (@{$p->{_attachments_}}) {
push @$parts, map { _mime_create(%$_) } @{$p->{_attachments_}};
+ $overall_type = 'multipart/mixed';
}
my $email = Email::MIME->create(
diff --git a/perllib/FixMyStreet/Geocode.pm b/perllib/FixMyStreet/Geocode.pm
index d552afaa5..61c968269 100644
--- a/perllib/FixMyStreet/Geocode.pm
+++ b/perllib/FixMyStreet/Geocode.pm
@@ -13,12 +13,14 @@ use JSON::MaybeXS;
use LWP::Simple qw($ua);
use Path::Tiny;
use URI::Escape;
-use FixMyStreet::Geocode::Bing;
-use FixMyStreet::Geocode::Google;
-use FixMyStreet::Geocode::OSM;
-use FixMyStreet::Geocode::Zurich;
use Utils;
+use Module::Pluggable
+ sub_name => 'geocoders',
+ search_path => __PACKAGE__,
+ require => 1,
+ except => qr/Address/;
+
# lookup STRING CONTEXT
# Given a user-inputted string, try and convert it into co-ordinates using either
# MaPit if it's a postcode, or some web API otherwise. Returns an array of
@@ -44,14 +46,17 @@ sub lookup {
sub string {
my ($s, $c) = @_;
- my $service = $c->cobrand->get_geocoder($c);
+ my $service = $c->cobrand->get_geocoder();
$service = $service->{type} if ref $service;
- $service = 'OSM' unless $service =~ /^(Bing|Google|OSM|Zurich)$/;
- $service = 'OSM' if $service eq 'Bing' && !FixMyStreet->config('BING_MAPS_API_KEY');
- $service = "FixMyStreet::Geocode::${service}::string";
- no strict 'refs';
- return &$service($s, $c);
+ $service = __PACKAGE__ . '::' . $service;
+ my %avail = map { $_ => 1 } __PACKAGE__->geocoders;
+
+ if (!$avail{$service} || ($service->can('setup') && !$service->setup)) {
+ $service = __PACKAGE__ . '::OSM';
+ }
+
+ return $service->string($s, $c);
}
# escape STRING CONTEXT
diff --git a/perllib/FixMyStreet/Geocode/Bexley.pm b/perllib/FixMyStreet/Geocode/Bexley.pm
new file mode 100644
index 000000000..8a1a886bb
--- /dev/null
+++ b/perllib/FixMyStreet/Geocode/Bexley.pm
@@ -0,0 +1,71 @@
+package FixMyStreet::Geocode::Bexley;
+use parent 'FixMyStreet::Geocode::OSM';
+
+use warnings;
+use strict;
+
+use URI::Escape;
+
+my $base = 'http://tilma.mysociety.org/mapserver/bexley?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetFeature&TYPENAME=Streets&outputFormat=geojson&Filter=%3CFilter%3E%3CPropertyIsLike%20wildcard=%27*%27%20singleChar=%27.%27%20escape=%27!%27%3E%3CPropertyName%3EADDRESS%3C/PropertyName%3E%3CLiteral%3E{{str}}%3C/Literal%3E%3C/PropertyIsLike%3E%3C/Filter%3E';
+
+# Data is ALL CAPS
+sub recase {
+ my $word = shift;
+ return $word if $word =~ /FP/;
+ return lc $word if $word =~ /^(AND|TO)$/;
+ return ucfirst lc $word;
+}
+
+sub string {
+ my ($cls, $s, $c) = @_;
+
+ my $osm = $cls->SUPER::string($s, $c);
+ my $js = query_layer($s);
+ return $osm unless $js && @{$js->{features}};
+
+ $c->stash->{geocoder_url} = $s;
+
+ my ( $error, @valid_locations, $latitude, $longitude, $address );
+ foreach (sort { $a->{properties}{ADDRESS} cmp $b->{properties}{ADDRESS} } @{$js->{features}}) {
+ my @lines = @{$_->{geometry}{coordinates}};
+ @lines = ([ @lines ]) if $_->{geometry}{type} eq 'LineString';
+ my @points = map { @$_ } @lines;
+ my $mid = int @points/2;
+ my $e = $points[$mid][0];
+ my $n = $points[$mid][1];
+ ( $latitude, $longitude ) = Utils::convert_en_to_latlon_truncated( $e, $n );
+ $address = sprintf("%s, %s", $_->{properties}{ADDRESS}, $_->{properties}{TOWN});
+ $address =~ s/([\w']+)/recase($1)/ge;
+ push @$error, {
+ address => $address,
+ latitude => $latitude,
+ longitude => $longitude
+ };
+ push (@valid_locations, $_);
+ }
+
+ if ($osm->{latitude}) { # one result from OSM
+ push @$error, {
+ address => $osm->{address},
+ latitude => $osm->{latitude},
+ longitude => $osm->{longitude},
+ };
+ return { error => $error };
+ }
+
+ if (ref $osm->{error} eq 'ARRAY') {
+ push @$error, @{$osm->{error}};
+ return { error => $error };
+ }
+
+ return { latitude => $latitude, longitude => $longitude, address => $address }
+ if scalar @valid_locations == 1;
+ return { error => $error };
+}
+
+sub query_layer {
+ my $s = uc shift;
+ $s = URI::Escape::uri_escape_utf8("*$s*");
+ (my $url = $base) =~ s/\{\{str\}\}/$s/;
+ return FixMyStreet::Geocode::cache('bexley', $url);
+}
diff --git a/perllib/FixMyStreet/Geocode/Bing.pm b/perllib/FixMyStreet/Geocode/Bing.pm
index 9e425441a..1d39d911f 100644
--- a/perllib/FixMyStreet/Geocode/Bing.pm
+++ b/perllib/FixMyStreet/Geocode/Bing.pm
@@ -11,13 +11,19 @@ use strict;
use FixMyStreet::Geocode;
use Utils;
+sub setup {
+ my $cls = shift;
+ return 1 if FixMyStreet->config('BING_MAPS_API_KEY');
+ return 0;
+}
+
# string STRING CONTEXT
# Looks up on Bing Maps API, and caches, a user-inputted location.
# Returns array of (LAT, LON, ERROR), where ERROR is either undef, a string, or
# an array of matches if there are more than one. The information in the query
# may be used to disambiguate the location in cobranded versions of the site.
sub string {
- my ( $s, $c ) = @_;
+ my ( $cls, $s, $c ) = @_;
my $params = $c->cobrand->disambiguate_location($s);
# Allow cobrand to fixup the user input
@@ -32,6 +38,7 @@ sub string {
$url .= '&userLocation=' . $params->{centre} if $params->{centre};
$url .= '&c=' . $params->{bing_culture} if $params->{bing_culture};
+ $c->stash->{geocoder_url} = $url;
my $js = FixMyStreet::Geocode::cache('bing', $url, 'key=' . FixMyStreet->config('BING_MAPS_API_KEY'));
if (!$js) {
return { error => _('Sorry, we could not parse that location. Please try again.') };
diff --git a/perllib/FixMyStreet/Geocode/Google.pm b/perllib/FixMyStreet/Geocode/Google.pm
index ad1881541..ffbad96ba 100644
--- a/perllib/FixMyStreet/Geocode/Google.pm
+++ b/perllib/FixMyStreet/Geocode/Google.pm
@@ -16,7 +16,7 @@ use URI::Escape;
# an array of matches if there are more than one. The information in the query
# may be used to disambiguate the location in cobranded versions of the site.
sub string {
- my ( $s, $c ) = @_;
+ my ( $cls, $s, $c ) = @_;
my $params = $c->cobrand->disambiguate_location($s);
# Allow cobrand to fixup the user input
@@ -49,6 +49,7 @@ sub string {
$url .= '&components=' . $components if $components;
+ $c->stash->{geocoder_url} = $url;
my $args = 'key=' . FixMyStreet->config('GOOGLE_MAPS_API_KEY');
my $js = FixMyStreet::Geocode::cache('google', $url, $args, qr/"status"\s*:\s*"(OVER_QUERY_LIMIT|REQUEST_DENIED|INVALID_REQUEST|UNKNOWN_ERROR)"/);
if (!$js) {
diff --git a/perllib/FixMyStreet/Geocode/OSM.pm b/perllib/FixMyStreet/Geocode/OSM.pm
index 0d296f299..20e653cf6 100644
--- a/perllib/FixMyStreet/Geocode/OSM.pm
+++ b/perllib/FixMyStreet/Geocode/OSM.pm
@@ -14,8 +14,8 @@ use Memcached;
use XML::Simple;
use Utils;
-my $osmapibase = "http://www.openstreetmap.org/api/";
-my $nominatimbase = "http://nominatim.openstreetmap.org/";
+my $osmapibase = "https://www.openstreetmap.org/api/";
+my $nominatimbase = "https://nominatim.openstreetmap.org/";
# string STRING CONTEXT
# Looks up on Nominatim, and caches, a user-inputted location.
@@ -23,7 +23,7 @@ my $nominatimbase = "http://nominatim.openstreetmap.org/";
# an array of matches if there are more than one. The information in the query
# may be used to disambiguate the location in cobranded versions of the site.
sub string {
- my ( $s, $c ) = @_;
+ my ( $cls, $s, $c ) = @_;
my $params = $c->cobrand->disambiguate_location($s);
# Allow cobrand to fixup the user input
@@ -47,19 +47,22 @@ sub string {
if $params->{country};
$url .= join('&', map { "$_=$query_params{$_}" } sort keys %query_params);
+ $c->stash->{geocoder_url} = $url;
my $js = FixMyStreet::Geocode::cache('osm', $url);
if (!$js) {
return { error => _('Sorry, we could not find that location.') };
}
- my ( $error, @valid_locations, $latitude, $longitude );
+ my ( $error, @valid_locations, $latitude, $longitude, $address );
foreach (@$js) {
$c->cobrand->call_hook(geocoder_munge_results => $_);
+ next unless $_->{display_name};
( $latitude, $longitude ) =
map { Utils::truncate_coordinate($_) }
( $_->{lat}, $_->{lon} );
+ $address = $_->{display_name};
push (@$error, {
- address => $_->{display_name},
+ address => $address,
icon => $_->{icon},
latitude => $latitude,
longitude => $longitude
@@ -67,7 +70,7 @@ sub string {
push (@valid_locations, $_);
}
- return { latitude => $latitude, longitude => $longitude } if scalar @valid_locations == 1;
+ return { latitude => $latitude, longitude => $longitude, address => $address } if scalar @valid_locations == 1;
return { error => $error };
}
diff --git a/perllib/FixMyStreet/Geocode/Zurich.pm b/perllib/FixMyStreet/Geocode/Zurich.pm
index c7bd9e9d9..b0c0b528e 100644
--- a/perllib/FixMyStreet/Geocode/Zurich.pm
+++ b/perllib/FixMyStreet/Geocode/Zurich.pm
@@ -24,6 +24,8 @@ sub setup_soap {
# Variables for the SOAP web service
my $geocoder = FixMyStreet->config('GEOCODER');
+ return unless ref $geocoder eq 'HASH';
+
my $url = $geocoder->{url};
my $username = $geocoder->{username};
my $password = $geocoder->{password};
@@ -49,6 +51,34 @@ sub setup_soap {
$method = SOAP::Data->name('getLocation95')->attr({ xmlns => $attr });
}
+sub admin_district {
+ my ($e, $n) = @_;
+
+ setup_soap();
+ return unless $soap;
+
+ my $attr = 'http://ch/geoz/fixmyzuerich/service';
+ my $bo = 'http://ch/geoz/fixmyzuerich/bo';
+ my $method = SOAP::Data->name('getInfoByLocation')->attr({ xmlns => $attr });
+ my $location = SOAP::Data->name(
+ 'location' => \SOAP::Data->value(
+ SOAP::Data->name('bo:easting', $e),
+ SOAP::Data->name('bo:northing', $n),
+ )
+ )->attr({ 'xmlns:bo' => $bo });
+ my $search = SOAP::Data->value($location);
+ my $result;
+ eval {
+ $result = $soap->call($method, $security, $search);
+ };
+ if ($@) {
+ warn $@ if FixMyStreet->config('STAGING_SITE');
+ return 'The geocoder appears to be down.';
+ }
+ $result = $result->result;
+ return $result;
+}
+
# string STRING CONTEXT
# Looks up on Zurich web service a user-inputted location.
# Returns array of (LAT, LON, ERROR), where ERROR is either undef, a string, or
@@ -60,13 +90,14 @@ sub setup_soap {
# versions of the site.
sub string {
- my ( $s, $c ) = @_;
+ my ( $cls, $s, $c ) = @_;
setup_soap();
my $cache_dir = path(FixMyStreet->config('GEO_CACHE'), 'zurich')->absolute(FixMyStreet->path_to());
my $cache_file = $cache_dir->child(md5_hex($s));
my $result;
+ $c->stash->{geocoder_url} = $s;
if (-s $cache_file && -M $cache_file <= 7 && !FixMyStreet->config('STAGING_SITE')) {
$result = retrieve($cache_file);
} else {
diff --git a/perllib/FixMyStreet/ImageMagick.pm b/perllib/FixMyStreet/ImageMagick.pm
index af9f56478..d9f643801 100644
--- a/perllib/FixMyStreet/ImageMagick.pm
+++ b/perllib/FixMyStreet/ImageMagick.pm
@@ -23,6 +23,26 @@ has image => (
},
);
+has width => (
+ is => 'rwp',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ return unless $self->image;
+ return $self->image->Get('width');
+ }
+);
+
+has height => (
+ is => 'rwp',
+ lazy => 1,
+ default => sub {
+ my $self = shift;
+ return unless $self->image;
+ return $self->image->Get('height');
+ }
+);
+
sub strip {
my $self = shift;
return $self unless $self->image;
@@ -35,6 +55,7 @@ sub rotate {
return $self unless $self->image;
my $err = $self->image->Rotate($direction);
return 0 if $err;
+ $self->_set_width_and_height();
return $self;
}
@@ -44,17 +65,21 @@ sub shrink {
return $self unless $self->image;
my $err = $self->image->Scale(geometry => "$size>");
throw Error::Simple("resize failed: $err") if "$err";
+ $self->_set_width_and_height();
return $self->strip;
}
-# Shrinks a picture to 90x60, cropping so that it is exactly that.
+# Shrinks a picture to a given dimension (defaults to 90x60(, cropping so that
+# it is exactly that.
sub crop {
- my $self = shift;
+ my ($self, $size) = @_;
+ $size //= '90x60';
return $self unless $self->image;
- my $err = $self->image->Resize( geometry => "90x60^" );
+ my $err = $self->image->Resize( geometry => "$size^" );
throw Error::Simple("resize failed: $err") if "$err";
- $err = $self->image->Extent( geometry => '90x60', gravity => 'Center' );
+ $err = $self->image->Extent( geometry => $size, gravity => 'Center' );
throw Error::Simple("resize failed: $err") if "$err";
+ $self->_set_width_and_height();
return $self->strip;
}
@@ -62,8 +87,17 @@ sub as_blob {
my $self = shift;
return $self->blob unless $self->image;
my @blobs = $self->image->ImageToBlob();
+ $self->_set_width_and_height();
$self->_set_image(undef);
return $blobs[0];
}
+sub _set_width_and_height {
+ my $self = shift;
+ return unless $self->image;
+ my ($width, $height) = $self->image->Get('width', 'height');
+ $self->_set_width($width);
+ $self->_set_height($height);
+}
+
1;
diff --git a/perllib/FixMyStreet/Integrations/ExorRDI.pm b/perllib/FixMyStreet/Integrations/ExorRDI.pm
deleted file mode 100644
index ce59df9be..000000000
--- a/perllib/FixMyStreet/Integrations/ExorRDI.pm
+++ /dev/null
@@ -1,250 +0,0 @@
-package FixMyStreet::Integrations::ExorRDI::Error;
-
-use Moo;
-with 'Throwable';
-
-has message => (is => 'ro');
-
-package FixMyStreet::Integrations::ExorRDI::CSV;
-
-use parent 'Text::CSV';
-
-sub add_row {
- my ($self, $data, @data) = @_;
- $self->combine(@data);
- push @$data, $self->string;
-}
-
-package FixMyStreet::Integrations::ExorRDI;
-
-use DateTime;
-use Moo;
-use Scalar::Util 'blessed';
-use FixMyStreet::DB;
-use namespace::clean;
-
-has [qw(start_date end_date inspection_date mark_as_processed)] => (
- is => 'ro',
- required => 1,
-);
-
-has user => (
- is => 'ro',
- coerce => sub {
- return $_[0] if blessed($_[0]) && $_[0]->isa('FixMyStreet::DB::Result::User');
- FixMyStreet::DB->resultset('User')->find( { id => $_[0] } )
- if $_[0];
- },
-);
-
-sub construct {
- my $self = shift;
-
- my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker('oxfordshire')->new;
- my $dtf = $cobrand->problems->result_source->storage->datetime_parser;
- my $now = DateTime->now(
- time_zone => FixMyStreet->time_zone || FixMyStreet->local_time_zone
- );
-
- my $tmo = $cobrand->traffic_management_options;
- my %tm_lookup = map { $tmo->[$_] => $_ + 1 } 0..$#$tmo;
-
- my $missed_cutoff = $now - DateTime::Duration->new( hours => 24 );
- my %params = (
- -and => [
- state => [ 'action scheduled' ],
- external_id => { '!=' => undef },
- -or => [
- -and => [
- 'admin_log_entries.action' => 'inspected',
- 'admin_log_entries.whenedited' => { '>=', $dtf->format_datetime($self->start_date) },
- 'admin_log_entries.whenedited' => { '<=', $dtf->format_datetime($self->end_date) },
- ],
- -and => [
- extra => { -not_like => '%rdi_processed%' },
- 'admin_log_entries.action' => 'inspected',
- 'admin_log_entries.whenedited' => { '<=', $dtf->format_datetime($missed_cutoff) },
- ]
- ]
- ]
- );
-
- $params{'admin_log_entries.user_id'} = $self->user->id if $self->user;
-
- my $problems = $cobrand->problems->search(
- \%params,
- {
- join => 'admin_log_entries',
- distinct => 1,
- }
- );
- FixMyStreet::Integrations::ExorRDI::Error->throw unless $problems->count;
-
- # A single RDI file might contain inspections from multiple inspectors, so
- # we need to group inspections by inspector within G records.
- my $inspectors = {};
- my $inspector_initials = {};
- while ( my $report = $problems->next ) {
- my $user = $report->inspection_log_entry->user;
- $inspectors->{$user->id} ||= [];
- push @{ $inspectors->{$user->id} }, $report;
- unless ( $inspector_initials->{$user->id} ) {
- $inspector_initials->{$user->id} = $user->get_extra_metadata('initials');
- }
- }
-
- my $csv = FixMyStreet::Integrations::ExorRDI::CSV->new({ binary => 1, eol => "" });
-
- my $p_count = 0;
- my $link_id = $cobrand->exor_rdi_link_id;
-
- # RDI first line is always the same
- my $body = [];
- $csv->add_row($body, "1", "1.8", "1.0.0.0", "ENHN", "");
-
- my $i = 0;
- foreach my $inspector_id (keys %$inspectors) {
- my $inspections = $inspectors->{$inspector_id};
- my $initials = $inspector_initials->{$inspector_id} || "XX";
-
- my %body_by_activity_code;
- foreach my $report (@$inspections) {
- my ($eastings, $northings) = $report->local_coords;
-
- my $location = "${eastings}E ${northings}N";
- $location = "[DID NOT USE MAP] $location" unless $report->used_map;
- my $closest_address = $cobrand->find_closest($report);
- if (%$closest_address) {
- $location .= " Nearest road: $closest_address->{name}." if $closest_address->{name};
- $location .= " Nearest postcode: $closest_address->{postcode}{postcode}." if $closest_address->{postcode};
- }
-
- my $traffic_information = $report->get_extra_metadata('traffic_information') || 'none';
- my $description = sprintf("%s %s %s %s",
- $report->external_id || "",
- $initials,
- 'TM' . ($tm_lookup{$traffic_information} || '0'),
- $report->get_extra_metadata('detailed_information') || "");
- # Maximum length of 180 characters total
- $description = substr($description, 0, 180);
- my $activity_code = $report->defect_type ?
- $report->defect_type->get_extra_metadata('activity_code')
- : 'MC';
- $body_by_activity_code{$activity_code} ||= [];
-
- $csv->add_row($body_by_activity_code{$activity_code},
- "I", # beginning of defect record
- $activity_code, # activity code - minor carriageway, also FC (footway)
- "", # empty field, can also be A (seen on MC) or B (seen on FC)
- sprintf("%03d", ++$i), # randomised sequence number
- $location, # defect location field, which we don't capture from inspectors
- $report->inspection_log_entry->whenedited->strftime("%H%M"), # defect time raised
- "","","","","","","", # empty fields
- "TM $traffic_information",
- $description, # defect description
- );
-
- my $defect_type = $report->defect_type ?
- $report->defect_type->get_extra_metadata('defect_code')
- : 'SFP2';
- $csv->add_row($body_by_activity_code{$activity_code},
- "J", # georeferencing record
- $defect_type, # defect type - SFP2: sweep and fill <1m2, POT2 also seen
- $report->response_priority ?
- $report->response_priority->external_id :
- "2", # priority of defect
- "","", # empty fields
- $eastings, # eastings
- $northings, # northings
- "","","","","" # empty fields
- );
-
- my $m_row_activity_code = $activity_code;
- $m_row_activity_code .= 'I' if length $activity_code == 1;
-
- $csv->add_row($body_by_activity_code{$activity_code},
- "M", # bill of quantities record
- "resolve", # permanent repair
- "","", # empty fields
- "/C$m_row_activity_code", # /C + activity code + perhaps an "I"
- "", "" # empty fields
- );
- }
-
- foreach my $activity_code (sort keys %body_by_activity_code) {
- $csv->add_row($body,
- "G", # start of an area/sequence
- $link_id, # area/link id, fixed value for our purposes
- "","", # must be empty
- $initials, # inspector initials
- $self->inspection_date->strftime("%y%m%d"), # date of inspection yymmdd
- "1600", # time of inspection hhmm, set to static value for now
- "D", # inspection variant, should always be D
- "INS", # inspection type, always INS
- "N", # Area of the county - north (N) or south (S)
- "", "", "", "" # empty fields
- );
-
- $csv->add_row($body,
- "H", # initial inspection type
- $activity_code # e.g. MC = minor carriageway
- );
-
- # List of I/J/M entries from above
- push @$body, @{$body_by_activity_code{$activity_code}};
-
- # end this group of defects with a P record
- $csv->add_row($body,
- "P", # end of area/sequence
- 0, # always 0
- 999999, # charging code, always 999999 in OCC
- );
- $p_count++;
- }
- }
-
- # end the RDI file with an X record
- my $record_count = $i;
- $csv->add_row($body,
- "X", # end of inspection record
- $p_count,
- $p_count,
- $record_count, # number of I records
- $record_count, # number of J records
- 0, 0, 0, # always zero
- $record_count, # number of M records
- 0, # always zero
- $p_count,
- 0, 0, 0 # error counts, always zero
- );
-
- if ($self->mark_as_processed) {
- # Mark all these problems are having been included in an RDI
- $problems->reset;
- while ( my $report = $problems->next ) {
- $report->set_extra_metadata('rdi_processed' => $now->strftime( '%Y-%m-%d %H:%M' ));
- $report->update;
- }
- }
-
- # The RDI format is very weird CSV - each line must be wrapped in
- # double quotes.
- return join "", map { "\"$_\"\r\n" } @$body;
-}
-
-has filename => (
- is => 'lazy',
- default => sub {
- my $self = shift;
- my $start = $self->inspection_date->strftime("%Y%m%d");
- my $end = $self->end_date->strftime("%Y%m%d");
- my $filename = sprintf("exor_defects-%s-%s.rdi", $start, $end);
- if ( $self->user ) {
- my $initials = $self->user->get_extra_metadata("initials") || "";
- $filename = sprintf("exor_defects-%s-%s-%s.rdi", $start, $end, $initials);
- }
- return $filename;
- },
-);
-
-1;
diff --git a/perllib/FixMyStreet/Map/BathNES.pm b/perllib/FixMyStreet/Map/BathNES.pm
deleted file mode 100644
index 45261a625..000000000
--- a/perllib/FixMyStreet/Map/BathNES.pm
+++ /dev/null
@@ -1,20 +0,0 @@
-# FixMyStreet:Map::BathNES
-# More JavaScript, for street assets
-
-package FixMyStreet::Map::BathNES;
-use base 'FixMyStreet::Map::OSM';
-
-use strict;
-
-sub map_javascript { [
- '/vendor/OpenLayers/OpenLayers.wfs.js',
- '/vendor/OpenLayers.Projection.OrdnanceSurvey.js',
- '/js/map-OpenLayers.js',
- '/js/map-OpenStreetMap.js',
- '/cobrands/fixmystreet-uk-councils/roadworks.js',
- '/cobrands/fixmystreet/assets.js',
- '/cobrands/bathnes/js.js',
- '/cobrands/bathnes/assets.js',
-] }
-
-1;
diff --git a/perllib/FixMyStreet/Map/Bristol.pm b/perllib/FixMyStreet/Map/Bristol.pm
index 99bdd26d7..70240a991 100644
--- a/perllib/FixMyStreet/Map/Bristol.pm
+++ b/perllib/FixMyStreet/Map/Bristol.pm
@@ -2,39 +2,15 @@
# Bristol use their own tiles on their cobrand
package FixMyStreet::Map::Bristol;
-use base 'FixMyStreet::Map::WMTSBase';
+use base 'FixMyStreet::Map::UKCouncilWMTS';
use strict;
-sub zoom_parameters {
- my $self = shift;
- my $params = {
- zoom_levels => scalar $self->scales,
- default_zoom => 5,
- min_zoom_level => 0,
- id_offset => 0,
- };
- return $params;
-}
+sub default_zoom { 5; }
-sub tile_parameters {
- my $self = shift;
- my $params = {
- urls => [ 'https://maps.bristol.gov.uk/arcgis/rest/services/base/2015_BCC_96dpi/MapServer/WMTS/tile' ],
- layer_names => [ '2015_BCC_96dpi' ],
- wmts_version => '1.0.0',
- layer_style => 'default',
- matrix_set => 'default028mm',
- suffix => '.png', # appended to tile URLs
- size => 256, # pixels
- dpi => 96,
- inches_per_unit => 39.3701, # BNG uses metres
- projection => 'EPSG:27700',
- origin_x => -5220400.0,
- origin_y => 4470200.0,
- };
- return $params;
-}
+sub urls { [ 'https://maps.bristol.gov.uk/arcgis/rest/services/base/2019_Q2_BCC_96dpi/MapServer/WMTS/tile' ] }
+
+sub layer_names { [ '2019_Q2_BCC_96dpi' ] }
sub scales {
my $self = shift;
@@ -56,29 +32,11 @@ sub copyright {
return '&copy; BCC';
}
-sub map_template { 'bristol' }
-
sub map_javascript { [
- '/vendor/OpenLayers/OpenLayers.bristol.js',
+ '/vendor/OpenLayers/OpenLayers.wmts.js',
'/js/map-OpenLayers.js',
'/js/map-wmts-base.js',
'/js/map-wmts-bristol.js',
- '/cobrands/fixmystreet/assets.js',
- '/cobrands/bristol/assets.js',
] }
-# Reproject a WGS84 lat/lon into BNG easting/northing
-sub reproject_from_latlon($$$) {
- my ($self, $lat, $lon) = @_;
- my ($x, $y) = Utils::convert_latlon_to_en($lat, $lon);
- return ($x, $y);
-}
-
-# Reproject a BNG easting/northing into WGS84 lat/lon
-sub reproject_to_latlon($$$) {
- my ($self, $x, $y) = @_;
- my ($lat, $lon) = Utils::convert_en_to_latlon($x, $y);
- return ($lat, $lon);
-}
-
1;
diff --git a/perllib/FixMyStreet/Map/Bromley.pm b/perllib/FixMyStreet/Map/Bromley.pm
index cd50cc1d1..518382fc0 100644
--- a/perllib/FixMyStreet/Map/Bromley.pm
+++ b/perllib/FixMyStreet/Map/Bromley.pm
@@ -9,18 +9,8 @@ use base 'FixMyStreet::Map::FMS';
use strict;
-sub map_javascript { [
- '/vendor/OpenLayers/OpenLayers.wfs.js',
- '/js/map-OpenLayers.js',
- '/js/map-bing-ol.js',
- '/js/map-fms.js',
- '/cobrands/fixmystreet/assets.js',
- '/cobrands/bromley/map.js',
- '/cobrands/bromley/assets.js',
-] }
-
sub map_tile_base {
- '-', "https://%sfix.bromley.gov.uk/tilma/%d/%d/%d.png";
+ '-', "//%stilma.mysociety.org/bromley/%d/%d/%d.png";
}
1;
diff --git a/perllib/FixMyStreet/Map/Buckinghamshire.pm b/perllib/FixMyStreet/Map/Buckinghamshire.pm
index 10ee2a080..4c1b8e9a0 100644
--- a/perllib/FixMyStreet/Map/Buckinghamshire.pm
+++ b/perllib/FixMyStreet/Map/Buckinghamshire.pm
@@ -2,19 +2,44 @@
# More JavaScript, for street assets
package FixMyStreet::Map::Buckinghamshire;
-use base 'FixMyStreet::Map::OSM';
+use base 'FixMyStreet::Map::UKCouncilWMTS';
use strict;
+sub default_zoom { 8; }
+
+sub urls { [ 'https://maps.buckscc.gov.uk/arcgis/rest/services/Bucks_Basemapping/MapServer/WMTS/tile' ] }
+
+sub layer_names { [ 'Bucks_Basemapping' ] }
+
+sub scales {
+ my $self = shift;
+ my @scales = (
+ '1000000',
+ '500000',
+ '250000',
+ '125000',
+ '64000',
+ '32000',
+ '16000',
+ '8000',
+ '4000',
+ '2000',
+ '1000',
+ );
+ return @scales;
+
+}
+
+sub copyright {
+ return '&copy; BCC';
+}
+
sub map_javascript { [
- '/vendor/OpenLayers/OpenLayers.wfs.js',
- '/vendor/OpenLayers.Projection.OrdnanceSurvey.js',
+ '/vendor/OpenLayers/OpenLayers.wmts.js',
'/js/map-OpenLayers.js',
- '/js/map-OpenStreetMap.js',
- '/cobrands/fixmystreet-uk-councils/roadworks.js',
- '/cobrands/fixmystreet/assets.js',
- '/cobrands/buckinghamshire/js.js',
- '/cobrands/buckinghamshire/assets.js',
+ '/js/map-wmts-base.js',
+ '/js/map-wmts-buckinghamshire.js',
] }
1;
diff --git a/perllib/FixMyStreet/Map/CheshireEast.pm b/perllib/FixMyStreet/Map/CheshireEast.pm
new file mode 100644
index 000000000..4e59f2593
--- /dev/null
+++ b/perllib/FixMyStreet/Map/CheshireEast.pm
@@ -0,0 +1,70 @@
+package FixMyStreet::Map::CheshireEast;
+use base 'FixMyStreet::Map::OSM';
+
+use strict;
+use Utils;
+
+use constant MIN_ZOOM_LEVEL => 7;
+
+sub map_javascript { [
+ '/vendor/OpenLayers/OpenLayers.wfs.js',
+ '/js/map-OpenLayers.js',
+ '/js/map-cheshireeast.js',
+] }
+
+sub tile_parameters { {
+ origin_x => -3276800,
+ origin_y => 3276800,
+} }
+
+sub resolutions { (
+ 1792.003584007169,
+ 896.0017920035843,
+ 448.0008960017922,
+ 224.0004480008961,
+ 112.000224000448,
+ 56.000112000224014,
+ 28.000056000111993,
+ 14.000028000056004,
+ 7.000014000028002,
+ 2.8000056000112004,
+ 1.4000028000056002,
+ 0.7000014000028001,
+ 0.35000070000140004,
+ 0.14000028000056003,
+) }
+
+my $url = 'https://maps-cache.cheshiresharedservices.gov.uk/maps/?wmts/CE_OS_AllBasemaps_COLOUR/oscce_grid/%d/%d/%d.jpeg&KEY=3a3f5c60eca1404ea114e6941c9d3895';
+
+sub map_tiles {
+ my ( $self, %params ) = @_;
+ my ( $x, $y, $z ) = ( $params{x_tile}, $params{y_tile}, $params{zoom_act} );
+ return [
+ sprintf($url, $z, $x-1, $y-1),
+ sprintf($url, $z, $x, $y-1),
+ sprintf($url, $z, $x-1, $y),
+ sprintf($url, $z, $x, $y),
+ ];
+}
+
+sub latlon_to_tile($$$$) {
+ my ($self, $lat, $lon, $zoom) = @_;
+ my ($x, $y) = eval { Utils::convert_latlon_to_en($lat, $lon) };
+ my $tile_params = $self->tile_parameters;
+ my $res = ($self->resolutions)[$zoom];
+ my $fx = ( $x - $tile_params->{origin_x} ) / ($res * 256);
+ my $fy = ( $tile_params->{origin_y} - $y ) / ($res * 256);
+ return ( $fx, $fy );
+}
+
+sub tile_to_latlon {
+ my ($self, $fx, $fy, $zoom) = @_;
+ my $tile_params = $self->tile_parameters;
+ my $res = ($self->resolutions)[$zoom];
+ my $x = $fx * $res * 256 + $tile_params->{origin_x};
+ my $y = $tile_params->{origin_y} - $fy * $res * 256;
+ my ($lat, $lon) = Utils::convert_en_to_latlon($x, $y);
+ return ( $lat, $lon );
+}
+
+1;
diff --git a/perllib/FixMyStreet/Map/GoogleOL.pm b/perllib/FixMyStreet/Map/GoogleOL.pm
index 44d0e77e7..7049b27d4 100644
--- a/perllib/FixMyStreet/Map/GoogleOL.pm
+++ b/perllib/FixMyStreet/Map/GoogleOL.pm
@@ -16,7 +16,7 @@ sub map_template { 'google-ol' }
sub map_javascript {
my $google_maps_url = "https://maps.googleapis.com/maps/api/js?v=3";
my $key = FixMyStreet->config('GOOGLE_MAPS_API_KEY');
- $google_maps_url .= "&amp;key=$key" if $key;
+ $google_maps_url .= "&key=$key" if $key;
[
$google_maps_url,
'/vendor/OpenLayers/OpenLayers.google.js',
diff --git a/perllib/FixMyStreet/Map/HighwaysEngland.pm b/perllib/FixMyStreet/Map/HighwaysEngland.pm
new file mode 100644
index 000000000..de44240a4
--- /dev/null
+++ b/perllib/FixMyStreet/Map/HighwaysEngland.pm
@@ -0,0 +1,8 @@
+package FixMyStreet::Map::HighwaysEngland;
+use base 'FixMyStreet::Map::FMS';
+
+use strict;
+
+use constant MIN_ZOOM_LEVEL => 12;
+
+1;
diff --git a/perllib/FixMyStreet/Map/Hounslow.pm b/perllib/FixMyStreet/Map/Hounslow.pm
new file mode 100644
index 000000000..d66188a83
--- /dev/null
+++ b/perllib/FixMyStreet/Map/Hounslow.pm
@@ -0,0 +1,63 @@
+# FixMyStreet:Map::Hounslow
+# Hounslow use their own tiles on their cobrand
+
+package FixMyStreet::Map::Hounslow;
+use base 'FixMyStreet::Map::UKCouncilWMTS';
+
+use strict;
+
+sub default_zoom { 5; }
+
+sub urls { [ 'https://gis.ringway.co.uk/server/rest/services/Hosted/HounslowOSBasemap/MapServer/WMTS/tile' ] }
+
+sub layer_names { [ 'Hosted_HounslowOSBasemap' ] }
+
+sub scales {
+ my $self = shift;
+ my @scales = (
+ # The first 5 levels don't load and are really zoomed-out, so
+ # they're not included here.
+ # '600000',
+ # '500000',
+ # '400000',
+ # '300000',
+ # '200000',
+ '100000',
+ '75000',
+ '50000',
+ '25000',
+ '10000',
+ '8000',
+ '6000',
+ '4000',
+ '2000',
+ '1000',
+ '400',
+ );
+ return @scales;
+}
+
+sub zoom_parameters {
+ my $self = shift;
+ my $params = {
+ zoom_levels => scalar $self->scales,
+ default_zoom => $self->default_zoom,
+ min_zoom_level => 0,
+ id_offset => 5, # see note above about zoom layers we've skipped
+ };
+ return $params;
+}
+
+sub copyright {
+ return 'Contains Ordnance Survey data &copy; Crown copyright and database rights 2019 OS. Use of this data is subject to <a href="/about/mapterms">terms and conditions</a>.';
+}
+
+
+sub map_javascript { [
+ '/vendor/OpenLayers/OpenLayers.wmts.js',
+ '/js/map-OpenLayers.js',
+ '/js/map-wmts-base.js',
+ '/js/map-wmts-hounslow.js',
+] }
+
+1;
diff --git a/perllib/FixMyStreet/Map/IsleOfWight.pm b/perllib/FixMyStreet/Map/IsleOfWight.pm
new file mode 100644
index 000000000..2316e2939
--- /dev/null
+++ b/perllib/FixMyStreet/Map/IsleOfWight.pm
@@ -0,0 +1,63 @@
+# FixMyStreet:Map::IsleOfWight
+# IsleOfWight use their own tiles on their cobrand
+
+package FixMyStreet::Map::IsleOfWight;
+use base 'FixMyStreet::Map::UKCouncilWMTS';
+
+use strict;
+
+sub default_zoom { 7; }
+
+sub urls { [ 'https://gis.ringway.co.uk/server/rest/services/Hosted/IOW_OS/MapServer/WMTS/tile' ] }
+
+sub layer_names { [ 'Hosted_IOW_OS' ] }
+
+sub scales {
+ my $self = shift;
+ my @scales = (
+ # The first 5 levels don't load and are really zoomed-out, so
+ # they're not included here.
+ # '600000',
+ # '500000',
+ # '400000',
+ # '300000',
+ # '200000',
+ '100000',
+ '75000',
+ '50000',
+ '25000',
+ '10000',
+ '8000',
+ '6000',
+ '4000',
+ '2000',
+ '1000',
+ '400',
+ );
+ return @scales;
+}
+
+sub zoom_parameters {
+ my $self = shift;
+ my $params = {
+ zoom_levels => scalar $self->scales,
+ default_zoom => $self->default_zoom,
+ min_zoom_level => 0,
+ id_offset => 5, # see note above about zoom layers we've skipped
+ };
+ return $params;
+}
+
+sub copyright {
+ return 'Contains Ordnance Survey data &copy; Crown copyright and database rights 2019 OS 100019229. Use of this data is subject to <a href="/about/mapterms">terms and conditions</a>.';
+}
+
+
+sub map_javascript { [
+ '/vendor/OpenLayers/OpenLayers.wmts.js',
+ '/js/map-OpenLayers.js',
+ '/js/map-wmts-base.js',
+ '/js/map-wmts-isleofwight.js',
+] }
+
+1;
diff --git a/perllib/FixMyStreet/Map/Lincolnshire.pm b/perllib/FixMyStreet/Map/Lincolnshire.pm
deleted file mode 100644
index 7dbfe5d8e..000000000
--- a/perllib/FixMyStreet/Map/Lincolnshire.pm
+++ /dev/null
@@ -1,21 +0,0 @@
-# FixMyStreet:Map::Lincolnshire
-# More JavaScript, for street assets
-
-package FixMyStreet::Map::Lincolnshire;
-use base 'FixMyStreet::Map::FMS';
-
-use strict;
-
-sub map_javascript { [
- '/vendor/OpenLayers/OpenLayers.wfs.js',
- '/vendor/OpenLayers.Projection.OrdnanceSurvey.js',
- '/js/map-OpenLayers.js',
- '/js/map-bing-ol.js',
- '/js/map-fms.js',
- '/cobrands/fixmystreet-uk-councils/roadworks.js',
- '/cobrands/fixmystreet/assets.js',
- '/cobrands/lincolnshire/roadworks.js',
- '/cobrands/lincolnshire/assets.js',
-] }
-
-1;
diff --git a/perllib/FixMyStreet/Map/MasterMap.pm b/perllib/FixMyStreet/Map/MasterMap.pm
new file mode 100644
index 000000000..d66234bbf
--- /dev/null
+++ b/perllib/FixMyStreet/Map/MasterMap.pm
@@ -0,0 +1,40 @@
+# FixMyStreet:Map::MasterMap
+#
+# A combination of FMS OS maps and our own tiles
+
+package FixMyStreet::Map::MasterMap;
+use base 'FixMyStreet::Map::FMS';
+
+use strict;
+
+use constant ZOOM_LEVELS => 7;
+
+sub map_template { 'fms' }
+
+sub map_javascript { [
+ '/vendor/OpenLayers/OpenLayers.wfs.js',
+ '/js/map-OpenLayers.js',
+ '/js/map-bing-ol.js',
+ '/js/map-fms.js',
+ '/js/map-mastermap.js',
+] }
+
+sub map_tiles {
+ my ( $self, %params ) = @_;
+ my ( $x, $y, $z ) = ( $params{x_tile}, $params{y_tile}, $params{zoom_act} );
+ if ($z >= 17) {
+ my $layer = FixMyStreet->config('STAGING_SITE') ? 'mastermap-staging' : 'mastermap';
+ my $base = "//%stilma.mysociety.org/$layer/%d/%d/%d.png";
+ return [
+ sprintf($base, 'a.', $z, $x-1, $y-1),
+ sprintf($base, 'b.', $z, $x, $y-1),
+ sprintf($base, 'c.', $z, $x-1, $y),
+ sprintf($base, '', $z, $x, $y),
+ ];
+ } else {
+ return $self->SUPER::map_tiles(%params);
+ }
+}
+
+1;
+
diff --git a/perllib/FixMyStreet/Map/Northamptonshire.pm b/perllib/FixMyStreet/Map/Northamptonshire.pm
new file mode 100644
index 000000000..81f7e45eb
--- /dev/null
+++ b/perllib/FixMyStreet/Map/Northamptonshire.pm
@@ -0,0 +1,85 @@
+package FixMyStreet::Map::Northamptonshire;
+use base 'FixMyStreet::Map::WMSBase';
+
+use strict;
+
+sub default_zoom { 8; }
+
+sub urls { [ 'https://maps.northamptonshire.gov.uk/interactivemappingwms/getmap.ashx' ] }
+
+sub layer_names{ [ 'BaseMap' ] }
+
+sub copyright {
+ return '&copy; NCC';
+}
+
+sub scales {
+ my $self = shift;
+ my @scales = (
+ '300000',
+ '200000',
+ '100000',
+ '75000',
+ '50000',
+ '25000',
+ '10000',
+ '8000',
+ '6000',
+ '4000',
+ '2000',
+ '1000',
+ '400',
+ );
+ return @scales;
+}
+sub tile_parameters {
+ my $self = shift;
+ my $params = {
+ urls => $self->urls,
+ layer_names => $self->layer_names,
+ wms_version => '1.1.1',
+ layer_style => 'default',
+ format => 'image/png', # appended to tile URLs
+ size => 256, # pixels
+ dpi => 96,
+ inches_per_unit => 39.3701,
+ projection => 'EPSG:27700',
+ };
+ return $params;
+}
+
+sub zoom_parameters {
+ my $self = shift;
+ my $params = {
+ zoom_levels => scalar $self->scales,
+ default_zoom => 7,
+ min_zoom_level => 1,
+ id_offset => 0,
+ };
+ return $params;
+}
+
+# Reproject a WGS84 lat/lon into BNG easting/northing
+sub reproject_from_latlon($$$) {
+ my ($self, $lat, $lon) = @_;
+ # do not try to reproject if we have no co-ordindates as convert breaks
+ return (0.0, 0.0) if $lat == 0 && $lon == 0;
+ my ($x, $y) = Utils::convert_latlon_to_en($lat, $lon);
+ return ($x, $y);
+}
+
+# Reproject a BNG easting/northing into WGS84 lat/lon
+sub reproject_to_latlon($$$) {
+ my ($self, $x, $y) = @_;
+ my ($lat, $lon) = Utils::convert_en_to_latlon($x, $y);
+ return ($lat, $lon);
+}
+
+sub map_javascript { [
+ '/vendor/OpenLayers/OpenLayers.wms.js',
+ '/js/map-OpenLayers.js',
+ '/js/map-wms-base.js',
+ '/js/map-wms-northamptonshire.js',
+] }
+
+1;
diff --git a/perllib/FixMyStreet/Map/OSM.pm b/perllib/FixMyStreet/Map/OSM.pm
index a6cb6acea..ef465d7dc 100644
--- a/perllib/FixMyStreet/Map/OSM.pm
+++ b/perllib/FixMyStreet/Map/OSM.pm
@@ -87,10 +87,10 @@ sub generate_map_data {
$zoom = $numZoomLevels - 1 if $zoom >= $numZoomLevels;
$zoom = 0 if $zoom < 0;
$params{zoom_act} = $zoomOffset + $zoom;
- ($params{x_tile}, $params{y_tile}) = latlon_to_tile_with_adjust($params{latitude}, $params{longitude}, $params{zoom_act});
+ ($params{x_tile}, $params{y_tile}) = $self->latlon_to_tile_with_adjust($params{latitude}, $params{longitude}, $params{zoom_act});
foreach my $pin (@{$params{pins}}) {
- ($pin->{px}, $pin->{py}) = latlon_to_px($pin->{latitude}, $pin->{longitude}, $params{x_tile}, $params{y_tile}, $params{zoom_act});
+ ($pin->{px}, $pin->{py}) = $self->latlon_to_px($pin->{latitude}, $pin->{longitude}, $params{x_tile}, $params{y_tile}, $params{zoom_act});
}
return {
@@ -102,24 +102,24 @@ sub generate_map_data {
zoom => $zoom,
zoomOffset => $zoomOffset,
numZoomLevels => $numZoomLevels,
- compass => compass( $params{x_tile}, $params{y_tile}, $params{zoom_act} ),
+ compass => $self->compass( $params{x_tile}, $params{y_tile}, $params{zoom_act} ),
};
}
sub compass {
- my ( $x, $y, $z ) = @_;
+ my ( $self, $x, $y, $z ) = @_;
return {
- north => [ map { Utils::truncate_coordinate($_) } tile_to_latlon( $x, $y-1, $z ) ],
- south => [ map { Utils::truncate_coordinate($_) } tile_to_latlon( $x, $y+1, $z ) ],
- west => [ map { Utils::truncate_coordinate($_) } tile_to_latlon( $x-1, $y, $z ) ],
- east => [ map { Utils::truncate_coordinate($_) } tile_to_latlon( $x+1, $y, $z ) ],
- here => [ map { Utils::truncate_coordinate($_) } tile_to_latlon( $x, $y, $z ) ],
+ north => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y-1, $z ) ],
+ south => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y+1, $z ) ],
+ west => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x-1, $y, $z ) ],
+ east => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x+1, $y, $z ) ],
+ here => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y, $z ) ],
};
}
# Given a lat/lon, convert it to OSM tile co-ordinates (precise).
-sub latlon_to_tile($$$) {
- my ($lat, $lon, $zoom) = @_;
+sub latlon_to_tile($$$$) {
+ my ($self, $lat, $lon, $zoom) = @_;
my $x_tile = ($lon + 180) / 360 * 2**$zoom;
my $y_tile = (1 - log(tan(deg2rad($lat)) + sec(deg2rad($lat))) / pi) / 2 * 2**$zoom;
return ( $x_tile, $y_tile );
@@ -127,9 +127,9 @@ sub latlon_to_tile($$$) {
# Given a lat/lon, convert it to OSM tile co-ordinates (nearest actual tile,
# adjusted so the point will be near the centre of a 2x2 tiled map).
-sub latlon_to_tile_with_adjust($$$) {
- my ($lat, $lon, $zoom) = @_;
- my ($x_tile, $y_tile) = latlon_to_tile($lat, $lon, $zoom);
+sub latlon_to_tile_with_adjust($$$$) {
+ my ($self, $lat, $lon, $zoom) = @_;
+ my ($x_tile, $y_tile) = $self->latlon_to_tile($lat, $lon, $zoom);
# Try and have point near centre of map
if ($x_tile - int($x_tile) > 0.5) {
@@ -143,7 +143,7 @@ sub latlon_to_tile_with_adjust($$$) {
}
sub tile_to_latlon {
- my ($x, $y, $zoom) = @_;
+ my ($self, $x, $y, $zoom) = @_;
my $n = 2 ** $zoom;
my $lon = $x / $n * 360 - 180;
my $lat = rad2deg(atan(sinh(pi * (1 - 2 * $y / $n))));
@@ -151,9 +151,9 @@ sub tile_to_latlon {
}
# Given a lat/lon, convert it to pixel co-ordinates from the top left of the map
-sub latlon_to_px($$$$$) {
- my ($lat, $lon, $x_tile, $y_tile, $zoom) = @_;
- my ($pin_x_tile, $pin_y_tile) = latlon_to_tile($lat, $lon, $zoom);
+sub latlon_to_px($$$$$$) {
+ my ($self, $lat, $lon, $x_tile, $y_tile, $zoom) = @_;
+ my ($pin_x_tile, $pin_y_tile) = $self->latlon_to_tile($lat, $lon, $zoom);
my $pin_x = tile_to_px($pin_x_tile, $x_tile);
my $pin_y = tile_to_px($pin_y_tile, $y_tile);
return ($pin_x, $pin_y);
@@ -182,8 +182,8 @@ sub click_to_wgs84 {
my ($self, $c, $pin_tile_x, $pin_x, $pin_tile_y, $pin_y) = @_;
my $tile_x = click_to_tile($pin_tile_x, $pin_x);
my $tile_y = click_to_tile($pin_tile_y, $pin_y);
- my $zoom = MIN_ZOOM_LEVEL + (defined $c->get_param('zoom') ? $c->get_param('zoom') : 3);
- my ($lat, $lon) = tile_to_latlon($tile_x, $tile_y, $zoom);
+ my $zoom = $self->MIN_ZOOM_LEVEL + (defined $c->get_param('zoom') ? $c->get_param('zoom') : 3);
+ my ($lat, $lon) = $self->tile_to_latlon($tile_x, $tile_y, $zoom);
return ( $lat, $lon );
}
diff --git a/perllib/FixMyStreet/Map/UKCouncilWMTS.pm b/perllib/FixMyStreet/Map/UKCouncilWMTS.pm
new file mode 100644
index 000000000..53b6859bf
--- /dev/null
+++ b/perllib/FixMyStreet/Map/UKCouncilWMTS.pm
@@ -0,0 +1,55 @@
+package FixMyStreet::Map::UKCouncilWMTS;
+use base 'FixMyStreet::Map::WMTSBase';
+
+use strict;
+
+sub zoom_parameters {
+ my $self = shift;
+ my $params = {
+ zoom_levels => scalar $self->scales,
+ default_zoom => $self->default_zoom,
+ min_zoom_level => 0,
+ id_offset => 0,
+ };
+ return $params;
+}
+
+sub tile_parameters {
+ my $self = shift;
+ my $params = {
+ urls => $self->urls,
+ layer_names => $self->layer_names,
+ wmts_version => '1.0.0',
+ layer_style => 'default',
+ matrix_set => 'default028mm',
+ suffix => '.png', # appended to tile URLs
+ size => 256, # pixels
+ dpi => 96,
+ inches_per_unit => 39.37, # BNG uses metres
+ projection => 'EPSG:27700',
+ origin_x => -5220400.0,
+ origin_y => 4470200.0,
+ };
+ return $params;
+}
+
+# Reproject a WGS84 lat/lon into BNG easting/northing
+sub reproject_from_latlon($$$) {
+ my ($self, $lat, $lon) = @_;
+ # do not try to reproject if we have no co-ordindates as convert breaks
+ return (0.0, 0.0) if $lat == 0 && $lon == 0;
+ my ($x, $y) = Utils::convert_latlon_to_en($lat, $lon);
+ return ($x, $y);
+}
+
+# Reproject a BNG easting/northing into WGS84 lat/lon
+sub reproject_to_latlon($$$) {
+ my ($self, $x, $y) = @_;
+ return (0,0) if $x<0 || $y<0;
+ my ($lat, $lon) = Utils::convert_en_to_latlon($x, $y);
+ return ($lat, $lon);
+}
+
+sub map_template { 'wmts' }
+
+1;
diff --git a/perllib/FixMyStreet/Map/WMSBase.pm b/perllib/FixMyStreet/Map/WMSBase.pm
new file mode 100644
index 000000000..ce8b6ab38
--- /dev/null
+++ b/perllib/FixMyStreet/Map/WMSBase.pm
@@ -0,0 +1,151 @@
+# FixMyStreet:Map::WMSBase
+# Makes it easier for cobrands to use their own WMS base map.
+# This cannot be used directly; you must subclass it and implement several
+# methods. See, e.g. FixMyStreet::Map::Northamptonshire.
+
+package FixMyStreet::Map::WMSBase;
+use parent FixMyStreet::Map::WMXBase;
+
+use strict;
+
+# A hash of parameters used in calculations for map tiles
+sub tile_parameters {
+ my $params = {
+ urls => [ '' ], # URL of the map tiles, up to the /{z}/{x}/{y} part
+ layer_names => [ '' ],
+ wms_version => '1.0.0',
+ size => 256, # pixels
+ dpi => 96,
+ inches_per_unit => 0, # See OpenLayers.INCHES_PER_UNIT for some options.
+ projection => 'EPSG:3857', # Passed through to OpenLayers.Projection
+ };
+ return $params;
+}
+
+# This is used to determine which template to render the map with
+sub map_template { 'wms' }
+
+sub get_res {
+ my ($self, $zoom) = @_;
+
+ my @scales = $self->scales;
+
+ my $res = $scales[$zoom] /
+ ($self->tile_parameters->{inches_per_unit} * $self->tile_parameters->{dpi});
+
+ return $res;
+}
+
+sub _get_tile_size {
+ my ($self, $params) = @_;
+
+ my $res = $self->get_res($params->{zoom});
+ return $res * $self->tile_parameters->{size};
+}
+
+sub _get_tile_params {
+ my ($self, $params, $left_col, $top_row, $z, $tile_url, $size) = @_;
+
+ my ($min_x, $min_y, $max_x, $max_y) = ($left_col, $top_row - $size, $left_col + $size, $top_row);
+
+ return ($tile_url, $min_x, $min_y, $max_x, $max_y);
+}
+
+sub _get_tile_src {
+ my ($self, $tile_url, $min_x, $min_y, $max_x, $max_y, $col, $row) = @_;
+
+ my $src = sprintf( '%s&bbox=%d,%d,%d,%d',
+ $tile_url, $min_x + $col, $min_y - $row, $max_x + $col, $max_y - $row);
+
+ return $src;
+}
+
+sub _get_tile_id {
+ my ($self, $tile_url, $min_x, $min_y, $max_x, $max_y, $col, $row) = @_;
+
+ return sprintf( '%d.%d', ($min_x + $col), ($min_y - $row) );
+}
+
+sub _get_row {
+ my ($self, $top_row, $row_offset, $size) = @_;
+ return $row_offset * $size;
+}
+
+sub _get_col {
+ my ($self, $left_col, $col_offset, $size) = @_;
+ return $col_offset * $size;
+}
+
+sub map_type { 'OpenLayers.Layer.WMS' }
+
+sub _map_hash_extras {
+ my $self = shift;
+
+ return {
+ wms_version => $self->tile_parameters->{wms_version},
+ format => $self->tile_parameters->{format},
+ };
+}
+
+sub tile_base_url {
+ my $self = shift;
+ my $params = $self->tile_parameters;
+ return sprintf '%s?version=%s&format=%s&size=%s&width=%s&height=%s&service=WMS&layers=%s&request=GetMap&srs=%s',
+ $params->{urls}[0], $params->{wms_version}, $params->{format}, $params->{size}, $params->{size},
+ $params->{size}, $params->{layer_names}[0], $params->{projection};
+}
+
+# Given a lat/lon, convert it to tile co-ordinates (nearest actual tile,
+# adjusted so the point will be near the centre of a 2x2 tiled map).
+sub latlon_to_tile_with_adjust {
+ my ($self, $lat, $lon, $zoom, $rows, $cols) = @_;
+ my ($x_tile, $y_tile)
+ = $self->reproject_from_latlon($lat, $lon, $zoom);
+
+ my $tile_params = $self->tile_parameters;
+ my $res = $self->get_res($zoom);
+
+ $x_tile = $x_tile - ($res * $tile_params->{size});
+ $y_tile = $y_tile + ($res * $tile_params->{size});
+
+ return ( int($x_tile), int($y_tile) );
+}
+
+sub tile_to_latlon {
+ my ($self, $fx, $fy, $zoom) = @_;
+ my ($lat, $lon) = $self->reproject_to_latlon($fx, $fy);
+
+ return ($lat, $lon);
+}
+
+# Given a lat/lon, convert it to pixel co-ordinates from the top left of the map
+sub latlon_to_px($$$$$$) {
+ my ($self, $lat, $lon, $x_tile, $y_tile, $zoom) = @_;
+ my ($pin_x_tile, $pin_y_tile) = $self->reproject_from_latlon($lat, $lon, $zoom);
+ my $res = $self->get_res($zoom);
+ my $pin_x = ( $pin_x_tile - $x_tile ) / $res;
+ my $pin_y = ( $y_tile - $pin_y_tile ) / $res;
+ return ($pin_x, $pin_y);
+}
+
+sub click_to_tile {
+ my ($self, $pin_tile, $pin, $zoom, $reverse) = @_;
+ my $tile_params = $self->tile_parameters;
+ my $size = $tile_params->{size};
+ my $res = $self->get_res($zoom);
+
+ return $reverse ? $pin_tile + ( ( $size - $pin ) * $res ) : $pin_tile + ( $pin * $res );
+}
+
+# Given some click co-ords (the tile they were on, and where in the
+# tile they were), convert to WGS84 and return.
+sub click_to_wgs84 {
+ my ($self, $c, $pin_tile_x, $pin_x, $pin_tile_y, $pin_y) = @_;
+ my $zoom = (defined $c->get_param('zoom') ? $c->get_param('zoom') : $self->zoom_parameters->{default_zoom});
+ my $tile_x = $self->click_to_tile($pin_tile_x, $pin_x, $zoom);
+ my $tile_y = $self->click_to_tile($pin_tile_y, $pin_y, $zoom, 1);
+ my ($lat, $lon) = $self->tile_to_latlon($tile_x, $tile_y, $zoom);
+ return ( $lat, $lon );
+}
+
+1;
diff --git a/perllib/FixMyStreet/Map/WMTSBase.pm b/perllib/FixMyStreet/Map/WMTSBase.pm
index 051f8f369..e482b3f37 100644
--- a/perllib/FixMyStreet/Map/WMTSBase.pm
+++ b/perllib/FixMyStreet/Map/WMTSBase.pm
@@ -4,40 +4,9 @@
# methods. See, e.g. FixMyStreet::Map::Zurich or FixMyStreet::Map::Bristol.
package FixMyStreet::Map::WMTSBase;
+use parent FixMyStreet::Map::WMXBase;
use strict;
-use Math::Trig;
-use Utils;
-use JSON::MaybeXS;
-
-sub scales {
- my $self = shift;
- my @scales = (
- # A list of scales corresponding to zoom levels, e.g.
- # '192000',
- # '96000',
- # '48000',
- # etc...
- );
- return @scales;
-}
-
-# The copyright string to display in the corner of the map.
-sub copyright {
- return '';
-}
-
-# A hash of parameters that control the zoom options for the map
-sub zoom_parameters {
- my $self = shift;
- my $params = {
- zoom_levels => scalar $self->scales,
- default_zoom => 0,
- min_zoom_level => 0,
- id_offset => 0,
- };
- return $params;
-}
# A hash of parameters used in calculations for map tiles
sub tile_parameters {
@@ -58,162 +27,46 @@ sub tile_parameters {
return $params;
}
-# This is used to determine which template to render the map with
-sub map_template { 'fms' }
+sub _get_tile_params {
+ my ($self, $params, $left_col, $top_row, $z, $tile_url) = @_;
-# Reproject a WGS84 lat/lon into an x/y coordinate in this map's CRS.
-# Subclasses will want to override this.
-sub reproject_from_latlon($$$) {
- my ($self, $lat, $lon) = @_;
- return (0.0, 0.0);
+ return ($tile_url, $z, $self->tile_parameters->{suffix});
}
-# Reproject a x/y coordinate from this map's CRS into WGS84 lat/lon
-# Subclasses will want to override this.
-sub reproject_to_latlon($$$) {
- my ($self, $x, $y) = @_;
- return (0.0, 0.0);
+sub _get_tile_src {
+ my ($self, $tile_url, $z, $suffix, $col, $row) = @_;
+
+ return sprintf( '%s/%d/%d/%d%s',
+ $tile_url, $z, $row, $col, $suffix);
}
+sub _get_tile_id {
+ my ($self, $tile_url, $x, $suffix, $col, $row) = @_;
-sub map_tiles {
- my ($self, %params) = @_;
- my ($left_col, $top_row, $z) = @params{'x_left_tile', 'y_top_tile', 'matrix_id'};
- my $tile_url = $self->tile_base_url;
- my $tile_suffix = $self->tile_parameters->{suffix};
- my $cols = $params{cols};
- my $rows = $params{rows};
-
- my @col_offsets = (0.. ($cols-1) );
- my @row_offsets = (0.. ($rows-1) );
-
- return [
- map {
- my $row_offset = $_;
- [
- map {
- my $col_offset = $_;
- my $row = $top_row + $row_offset;
- my $col = $left_col + $col_offset;
- my $src = sprintf '%s/%d/%d/%d%s',
- $tile_url, $z, $row, $col, $tile_suffix;
- my $dotted_id = sprintf '%d.%d', $col, $row;
-
- # return the data structure for the cell
- +{
- src => $src,
- row_offset => $row_offset,
- col_offset => $col_offset,
- dotted_id => $dotted_id,
- alt => "Map tile $dotted_id", # TODO "NW map tile"?
- }
- }
- @col_offsets
- ]
- }
- @row_offsets
- ];
+ return sprintf( '%d.%d', $col, $row);
}
-# display_map C PARAMS
-# PARAMS include:
-# latitude, longitude for the centre point of the map
-# CLICKABLE is set if the map is clickable
-# PINS is array of pins to show, location and colour
-sub display_map {
- my ($self, $c, %params) = @_;
-
- # Map centre may be overridden in the query string
- $params{latitude} = Utils::truncate_coordinate($c->get_param('lat') + 0)
- if defined $c->get_param('lat');
- $params{longitude} = Utils::truncate_coordinate($c->get_param('lon') + 0)
- if defined $c->get_param('lon');
-
- $params{rows} //= 2; # 2x2 square is default
- $params{cols} //= 2;
-
- my $zoom_params = $self->zoom_parameters;
-
- $params{zoom} = do {
- my $zoom = defined $c->get_param('zoom')
- ? $c->get_param('zoom') + 0
- : $c->stash->{page} eq 'report'
- ? $zoom_params->{default_zoom}+1
- : $zoom_params->{default_zoom};
- $zoom = $zoom_params->{zoom_levels} - 1
- if $zoom >= $zoom_params->{zoom_levels};
- $zoom = 0 if $zoom < 0;
- $zoom;
- };
+sub _get_row {
+ my ($self, $top_row, $row_offset, $size) = @_;
+ return $top_row + $row_offset;
+}
- $c->stash->{map} = $self->get_map_hash( %params );
-
- if ($params{print_report}) {
- $params{zoom}++ unless $params{zoom} >= $zoom_params->{zoom_levels};
- $c->stash->{print_report_map}
- = $self->get_map_hash(
- %params,
- img_type => 'img',
- cols => 4, rows => 4,
- );
- # NB: we can passthrough img_type as literal here, as only designed for print
-
- # NB we can do arbitrary size, including non-squares, however we'd have
- # to modify .square-map style with padding-bottom percentage calculated in
- # an inline style:
- # <zarino> in which case, the only change that'd be required is
- # removing { padding-bottom: 100% } from .square-map__outer, putting
- # the percentage into an inline style on the element itself, and then
- # probably renaming .square-map__* to .fixed-aspect-map__* or something
- # since it's no longer necessarily square
- }
+sub _get_col {
+ my ($self, $left_col, $col_offset, $size) = @_;
+ return $left_col + $col_offset;
}
-sub get_map_hash {
- my ($self, %params) = @_;
-
- @params{'x_centre_tile', 'y_centre_tile', 'matrix_id'}
- = $self->latlon_to_tile_with_adjust(
- @params{'latitude', 'longitude', 'zoom', 'rows', 'cols'});
-
- # centre_(row|col) is either in middle, or just to right.
- # e.g. if centre is the number in parens:
- # 1 (2) 3 => 2 - int( 3/2 ) = 1
- # 1 2 (3) 4 => 3 - int( 4/2 ) = 1
- $params{x_left_tile} = $params{x_centre_tile} - int($params{cols} / 2);
- $params{y_top_tile} = $params{y_centre_tile} - int($params{rows} / 2);
-
- $params{pins} = [
- map {
- my $pin = { %$_ }; # shallow clone
- ($pin->{px}, $pin->{py})
- = $self->latlon_to_px($pin->{latitude}, $pin->{longitude},
- @params{'x_left_tile', 'y_top_tile', 'zoom'});
- $pin;
- } @{ $params{pins} }
- ];
+sub map_type { 'OpenLayers.Layer.WMTS' }
+
+sub _map_hash_extras {
+ my $self = shift;
- my @scales = $self->scales;
return {
- %params,
- type => $self->map_template,
- map_type => 'OpenLayers.Layer.WMTS',
- tiles => $self->map_tiles( %params ),
- copyright => $self->copyright(),
- zoom => $params{zoom},
- zoomOffset => $self->zoom_parameters->{min_zoom_level},
- numZoomLevels => $self->zoom_parameters->{zoom_levels},
- tile_size => $self->tile_parameters->{size},
- tile_dpi => $self->tile_parameters->{dpi},
- tile_urls => encode_json( $self->tile_parameters->{urls} ),
- tile_suffix => $self->tile_parameters->{suffix},
- layer_names => encode_json( $self->tile_parameters->{layer_names} ),
layer_style => $self->tile_parameters->{layer_style},
matrix_set => $self->tile_parameters->{matrix_set},
- map_projection => $self->tile_parameters->{projection},
origin_x => force_float_format($self->tile_parameters->{origin_x}),
origin_y => force_float_format($self->tile_parameters->{origin_y}),
- scales => encode_json( \@scales ),
+ tile_suffix => $self->tile_parameters->{suffix},
};
}
diff --git a/perllib/FixMyStreet/Map/WMXBase.pm b/perllib/FixMyStreet/Map/WMXBase.pm
new file mode 100644
index 000000000..bc529817e
--- /dev/null
+++ b/perllib/FixMyStreet/Map/WMXBase.pm
@@ -0,0 +1,199 @@
+# FixMyStreet:Map::WMXBase
+# Common methods for WMS and WMTS maps
+
+package FixMyStreet::Map::WMXBase;
+
+use strict;
+use Math::Trig;
+use Utils;
+use JSON::MaybeXS;
+
+sub scales {
+ my $self = shift;
+ my @scales = (
+ # A list of scales corresponding to zoom levels, e.g.
+ # '192000',
+ # '96000',
+ # '48000',
+ # etc...
+ );
+ return @scales;
+}
+
+# The copyright string to display in the corner of the map.
+sub copyright {
+ return '';
+}
+
+# A hash of parameters that control the zoom options for the map
+sub zoom_parameters {
+ my $self = shift;
+ my $params = {
+ zoom_levels => scalar $self->scales,
+ default_zoom => 0,
+ min_zoom_level => 0,
+ id_offset => 0,
+ };
+ return $params;
+}
+
+# This is used to determine which template to render the map with
+sub map_template { 'fms' }
+
+# Reproject a WGS84 lat/lon into an x/y coordinate in this map's CRS.
+# Subclasses will want to override this.
+sub reproject_from_latlon($$$) {
+ my ($self, $lat, $lon) = @_;
+ return (0.0, 0.0);
+}
+
+# Reproject a x/y coordinate from this map's CRS into WGS84 lat/lon
+# Subclasses will want to override this.
+sub reproject_to_latlon($$$) {
+ my ($self, $x, $y) = @_;
+ return (0.0, 0.0);
+}
+
+sub _get_tile_size {
+ return shift->tile_parameters->{size};
+}
+
+sub map_tiles {
+ my ($self, %params) = @_;
+ my ($left_col, $top_row, $z) = @params{'x_left_tile', 'y_top_tile', 'matrix_id'};
+ my $tile_url = $self->tile_base_url;
+ my $cols = $params{cols};
+ my $rows = $params{rows};
+
+ my @col_offsets = (0.. ($cols-1) );
+ my @row_offsets = (0.. ($rows-1) );
+
+ my $size = $self->_get_tile_size(\%params);
+ my @params = $self->_get_tile_params(\%params, $left_col, $top_row, $z, $tile_url, $size);
+
+ return [
+ map {
+ my $row_offset = $_;
+ [
+ map {
+ my $col_offset = $_;
+ my $row = $self->_get_row($top_row, $row_offset, $size);
+ my $col = $self->_get_col($left_col, $col_offset, $size);
+ my $src = $self->_get_tile_src(@params, $col, $row);
+ my $dotted_id = $self->_get_tile_id(@params, $col, $row);
+
+ # return the data structure for the cell
+ +{
+ src => $src,
+ row_offset => $row_offset,
+ col_offset => $col_offset,
+ dotted_id => $dotted_id,
+ alt => "Map tile $dotted_id",
+ }
+ }
+ @col_offsets
+ ]
+ }
+ @row_offsets
+ ];
+}
+
+# display_map C PARAMS
+# PARAMS include:
+# latitude, longitude for the centre point of the map
+# CLICKABLE is set if the map is clickable
+# PINS is array of pins to show, location and colour
+sub display_map {
+ my ($self, $c, %params) = @_;
+
+ # Map centre may be overridden in the query string
+ $params{latitude} = Utils::truncate_coordinate($c->get_param('lat') + 0)
+ if defined $c->get_param('lat');
+ $params{longitude} = Utils::truncate_coordinate($c->get_param('lon') + 0)
+ if defined $c->get_param('lon');
+
+ $params{rows} //= 2; # 2x2 square is default
+ $params{cols} //= 2;
+
+ my $zoom_params = $self->zoom_parameters;
+
+ $params{zoom} = do {
+ my $zoom = defined $c->get_param('zoom')
+ ? $c->get_param('zoom') + 0
+ : $c->stash->{page} eq 'report'
+ ? $zoom_params->{default_zoom}+1
+ : $zoom_params->{default_zoom};
+ $zoom = $zoom_params->{zoom_levels} - 1
+ if $zoom >= $zoom_params->{zoom_levels};
+ $zoom = 0 if $zoom < 0;
+ $zoom;
+ };
+
+ $c->stash->{map} = $self->get_map_hash( %params );
+
+ if ($params{print_report}) {
+ $params{zoom}++ unless $params{zoom} >= $zoom_params->{zoom_levels};
+ $c->stash->{print_report_map}
+ = $self->get_map_hash(
+ %params,
+ img_type => 'img',
+ cols => 4, rows => 4,
+ );
+ }
+}
+
+sub _map_hash_extras { return {} }
+
+sub get_map_hash {
+ my ($self, %params) = @_;
+
+ @params{'x_centre_tile', 'y_centre_tile', 'matrix_id'}
+ = $self->latlon_to_tile_with_adjust(
+ @params{'latitude', 'longitude', 'zoom', 'rows', 'cols'});
+
+ $params{x_left_tile} = $params{x_centre_tile} - int($params{cols} / 2);
+ $params{y_top_tile} = $params{y_centre_tile} - int($params{rows} / 2);
+
+ $params{pins} = [
+ map {
+ my $pin = { %$_ }; # shallow clone
+ ($pin->{px}, $pin->{py})
+ = $self->latlon_to_px($pin->{latitude}, $pin->{longitude},
+ @params{'x_left_tile', 'y_top_tile', 'zoom'});
+ $pin;
+ } @{ $params{pins} }
+ ];
+
+ my @scales = $self->scales;
+ return {
+ %params,
+ type => $self->map_template,
+ map_type => $self->map_type,
+ tiles => $self->map_tiles( %params ),
+ copyright => $self->copyright(),
+ zoom => $params{zoom},
+ zoomOffset => $self->zoom_parameters->{min_zoom_level},
+ numZoomLevels => $self->zoom_parameters->{zoom_levels},
+ tile_size => $self->tile_parameters->{size},
+ tile_dpi => $self->tile_parameters->{dpi},
+ tile_urls => encode_json( $self->tile_parameters->{urls} ),
+ layer_names => encode_json( $self->tile_parameters->{layer_names} ),
+ map_projection => $self->tile_parameters->{projection},
+ scales => encode_json( \@scales ),
+ compass => $self->compass( $params{x_centre_tile}, $params{y_centre_tile}, $params{zoom} ),
+ %{ $self->_map_hash_extras },
+ };
+}
+
+sub compass {
+ my ( $self, $x, $y, $z ) = @_;
+ return {
+ north => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y-1, $z ) ],
+ south => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y+1, $z ) ],
+ west => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x-1, $y, $z ) ],
+ east => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x+1, $y, $z ) ],
+ here => [ map { Utils::truncate_coordinate($_) } $self->tile_to_latlon( $x, $y, $z ) ],
+ };
+}
+
+1;
diff --git a/perllib/FixMyStreet/MapIt.pm b/perllib/FixMyStreet/MapIt.pm
index d0a5f4760..238c5c62c 100644
--- a/perllib/FixMyStreet/MapIt.pm
+++ b/perllib/FixMyStreet/MapIt.pm
@@ -10,7 +10,7 @@ sub call {
# point in specifying it for that. 'areas' similarly if given IDs, but we
# might be looking up types or names, so might as well specify it then.
$opts{generation} = FixMyStreet->config('MAPIT_GENERATION')
- if $url ne 'area' && FixMyStreet->config('MAPIT_GENERATION');
+ if !$opts{generation} && $url ne 'area' && FixMyStreet->config('MAPIT_GENERATION');
return mySociety::MaPit::call($url, $params, %opts);
}
diff --git a/perllib/FixMyStreet/PhotoStorage.pm b/perllib/FixMyStreet/PhotoStorage.pm
index a441fb718..256d46361 100644
--- a/perllib/FixMyStreet/PhotoStorage.pm
+++ b/perllib/FixMyStreet/PhotoStorage.pm
@@ -1,5 +1,6 @@
package FixMyStreet::PhotoStorage;
+use MIME::Base64;
use Moose;
use Digest::SHA qw(sha1_hex);
use Module::Load;
@@ -37,5 +38,35 @@ sub get_fileid {
}
+=head2 base64_decode_upload
+
+base64 decode the temporary on-disk uploaded file if
+it's encoded that way. Modifies the file in-place.
+Catalyst::Request::Upload doesn't do this automatically
+unfortunately.
+
+=cut
+
+sub base64_decode_upload {
+ my ( $c, $upload ) = @_;
+
+ my $transfer_encoding = $upload->headers->header('Content-Transfer-Encoding');
+ if (defined $transfer_encoding && $transfer_encoding eq 'base64') {
+ my $decoded = decode_base64($upload->slurp);
+ if (open my $fh, '>', $upload->tempname) {
+ binmode $fh;
+ print $fh $decoded;
+ close $fh
+ } else {
+ if ($c) {
+ $c->log->info('Couldn\'t open temp file to save base64 decoded image: ' . $!);
+ $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again.");
+ }
+ return ();
+ }
+ }
+
+}
+
1;
diff --git a/perllib/FixMyStreet/Queue/Item/Report.pm b/perllib/FixMyStreet/Queue/Item/Report.pm
new file mode 100644
index 000000000..e38987838
--- /dev/null
+++ b/perllib/FixMyStreet/Queue/Item/Report.pm
@@ -0,0 +1,298 @@
+package FixMyStreet::Queue::Item::Report;
+
+use Moo;
+use DateTime::Format::Pg;
+
+use Utils::OpenStreetMap;
+
+use FixMyStreet;
+use FixMyStreet::Cobrand;
+use FixMyStreet::DB;
+use FixMyStreet::Email;
+use FixMyStreet::Map;
+use FixMyStreet::SendReport;
+
+# The row from the database being processed
+has report => ( is => 'ro' );
+# The thing dealing with the reports (for feeding back debug/test data)
+has manager => ( is => 'ro' );
+
+# The possible ways of sending reports
+has senders => ( is => 'lazy', default => sub {
+ my $send_report = FixMyStreet::SendReport->new();
+ $send_report->get_senders;
+});
+
+# The cobrand the report was logged *on*
+has cobrand => ( is => 'lazy', default => sub {
+ $_[0]->report->get_cobrand_logged;
+});
+
+# A cobrand that handles the body to which this report is being sent, or logged cobrand if none
+has cobrand_handler => ( is => 'lazy', default => sub {
+ my $self = shift;
+ $self->cobrand->call_hook(get_body_handler_for_problem => $self->report) || $self->cobrand;
+});
+
+# Data to be used in email templates / Open311 sending
+has h => ( is => 'rwp' );
+
+# SendReport subclasses to be used to send this report
+has reporters => ( is => 'rwp' );
+
+# Run parameters
+has verbose => ( is => 'ro');
+has nomail => ( is => 'ro' );
+
+sub process {
+ my $self = shift;
+
+ FixMyStreet::DB->schema->cobrand($self->cobrand);
+
+ if ($self->verbose) {
+ my $row = $self->report;
+ $self->log("state=" . $row->state . ", bodies_str=" . $row->bodies_str . ($row->cobrand? ", cobrand=" . $row->cobrand : ""));
+ }
+
+ # Cobranded and non-cobranded messages can share a database. In this case, the conf file
+ # should specify a vhost to send the reports for each cobrand, so that they don't get sent
+ # more than once if there are multiple vhosts running off the same database. The email_host
+ # call checks if this is the host that sends mail for this cobrand.
+ if (! $self->cobrand->email_host()) {
+ $self->log("skipping because this host does not send reports for cobrand " . $self->cobrand->moniker);
+ return;
+ }
+
+ $self->cobrand->set_lang_and_domain($self->report->lang, 1);
+ FixMyStreet::Map::set_map_class($self->cobrand_handler->map_type);
+
+ return unless $self->_check_abuse;
+ $self->_create_vars;
+ $self->_create_reporters or return;
+ my $result = $self->_send;
+ $self->_post_send($result);
+}
+
+sub _check_abuse {
+ my $self = shift;
+ if ( $self->report->is_from_abuser) {
+ $self->report->update( { state => 'hidden' } );
+ $self->log("hiding because its sender is flagged as an abuser");
+ return;
+ } elsif ( $self->report->title =~ /app store test/i ) {
+ $self->report->update( { state => 'hidden' } );
+ $self->log("hiding because it is an app store test message");
+ return;
+ }
+ return 1;
+}
+
+sub _create_vars {
+ my $self = shift;
+
+ my $row = $self->report;
+
+ # Template variables for the email
+ my $email_base_url = $self->cobrand_handler->base_url_for_report($row);
+ my %h = map { $_ => $row->$_ } qw/id title detail name category latitude longitude used_map/;
+ $h{report} = $row;
+ $h{cobrand} = $self->cobrand;
+ map { $h{$_} = $row->user->$_ || '' } qw/email phone/;
+ $h{confirmed} = DateTime::Format::Pg->format_datetime( $row->confirmed->truncate (to => 'second' ) )
+ if $row->confirmed;
+
+ $h{query} = $row->postcode;
+ $h{url} = $email_base_url . $row->url;
+ $h{admin_url} = $row->admin_url($self->cobrand_handler);
+ if ($row->photo) {
+ $h{has_photo} = _("This web page also contains a photo of the problem, provided by the user.") . "\n\n";
+ $h{image_url} = $email_base_url . $row->photos->[0]->{url_full};
+ my @all_images = map { $email_base_url . $_->{url_full} } @{ $row->photos };
+ $h{all_image_urls} = \@all_images;
+ } else {
+ $h{has_photo} = '';
+ $h{image_url} = '';
+ }
+ $h{fuzzy} = $row->used_map ? _('To view a map of the precise location of this issue')
+ : _('The user could not locate the problem on a map, but to see the area around the location they entered');
+ $h{closest_address} = '';
+
+ $h{osm_url} = Utils::OpenStreetMap::short_url($h{latitude}, $h{longitude});
+ if ( $row->used_map ) {
+ $h{closest_address} = $self->cobrand->find_closest($row);
+ $h{osm_url} .= '?m';
+ }
+
+ if ( $self->cobrand->allow_anonymous_reports($row->category) &&
+ $row->user->email eq $self->cobrand->anonymous_account->{'email'}
+ ) {
+ $h{anonymous_report} = 1;
+ }
+
+ if ($h{category} eq _('Other')) {
+ $h{category_footer} = _('this type of local problem');
+ } else {
+ $h{category_footer} = "'" . $h{category} . "'";
+ }
+
+ my $missing;
+ if ($row->bodies_missing) {
+ my @missing = FixMyStreet::DB->resultset("Body")->search(
+ { id => [ split /,/, $row->bodies_missing ] },
+ { order_by => 'name' }
+ )->get_column('name')->all;
+ $missing = join(' / ', @missing) if @missing;
+ }
+ $h{missing} = '';
+ if ($missing) {
+ $h{missing} = '[ '
+ . sprintf(_('We realise this problem might be the responsibility of %s; however, we don\'t currently have any contact details for them. If you know of an appropriate contact address, please do get in touch.'), $missing)
+ . " ]\n\n";
+ }
+
+ # If we are in the UK include eastings and northings
+ if ( $self->cobrand->country eq 'GB' && !$h{easting} ) {
+ ( $h{easting}, $h{northing}, $h{coordsyst} ) = $row->local_coords;
+ }
+
+ $self->cobrand->call_hook(process_additional_metadata_for_email => $row, \%h);
+
+ $self->_set_h(\%h);
+}
+
+sub _create_reporters {
+ my $self = shift;
+
+ my $row = $self->report;
+ my $bodies = FixMyStreet::DB->resultset('Body')->search(
+ { id => $row->bodies_str_ids },
+ { order_by => 'name' },
+ );
+
+ my @dear;
+ my %reporters = ();
+ while (my $body = $bodies->next) {
+ my $sender_info = $self->cobrand->get_body_sender( $body, $row->category );
+ my $sender = "FixMyStreet::SendReport::" . $sender_info->{method};
+
+ if ( ! exists $self->senders->{ $sender } ) {
+ $self->log(sprintf "No such sender [ $sender ] for body %s ( %d )", $body->name, $body->id);
+ next;
+ }
+ $reporters{ $sender } ||= $sender->new();
+
+ $self->log("Adding recipient body " . $body->id . ":" . $body->name . ", " . $sender_info->{method});
+ push @dear, $body->name;
+ $reporters{ $sender }->add_body( $body, $sender_info->{config} );
+ }
+
+ unless ( keys %reporters ) {
+ die 'Report not going anywhere for ID ' . $row->id . '!';
+ }
+
+ my $h = $self->h;
+ $h->{bodies_name} = join(_(' and '), @dear);
+ if ($h->{category} eq _('Other')) {
+ $h->{multiple} = @dear>1 ? "[ " . _("This email has been sent to both councils covering the location of the problem, as the user did not categorise it; please ignore it if you're not the correct council to deal with the issue, or let us know what category of problem this is so we can add it to our system.") . " ]\n\n"
+ : '';
+ } else {
+ $h->{multiple} = @dear>1 ? "[ " . _("This email has been sent to several councils covering the location of the problem, as the category selected is provided for all of them; please ignore it if you're not the correct council to deal with the issue.") . " ]\n\n"
+ : '';
+ }
+
+ if (FixMyStreet->staging_flag('send_reports', 0)) {
+ # on a staging server send emails to ourselves rather than the bodies
+ %reporters = map { $_ => $reporters{$_} } grep { /FixMyStreet::SendReport::Email/ } keys %reporters;
+ unless (%reporters) {
+ %reporters = ( 'FixMyStreet::SendReport::Email' => FixMyStreet::SendReport::Email->new() );
+ }
+ }
+
+ $self->_set_reporters(\%reporters);
+}
+
+sub _send {
+ my $self = shift;
+
+ # Multiply results together, so one success counts as a success.
+ my $result = -1;
+
+ for my $sender ( keys %{$self->reporters} ) {
+ $self->log("Sending using " . $sender);
+ $sender = $self->reporters->{$sender};
+ my $res = $sender->send( $self->report, $self->h );
+ $result *= $res;
+ $self->report->add_send_method($sender) if !$res;
+ if ( $self->manager ) {
+ if ($sender->unconfirmed_data) {
+ foreach my $e (keys %{ $sender->unconfirmed_data } ) {
+ foreach my $c (keys %{ $sender->unconfirmed_data->{$e} }) {
+ $self->manager->unconfirmed_data->{$e}{$c}{count} += $sender->unconfirmed_data->{$e}{$c}{count};
+ $self->manager->unconfirmed_data->{$e}{$c}{note} = $sender->unconfirmed_data->{$e}{$c}{note};
+ }
+ }
+ }
+ $self->manager->test_data->{test_req_used} = $sender->open311_test_req_used
+ if FixMyStreet->test_mode && $sender->can('open311_test_req_used');
+ }
+ }
+
+ return $result;
+}
+
+sub _post_send {
+ my ($self, $result) = @_;
+
+ my $send_confirmation_email = $self->cobrand_handler->report_sent_confirmation_email;
+ unless ($result) {
+ $self->report->update( {
+ whensent => \'current_timestamp',
+ lastupdate => \'current_timestamp',
+ } );
+ if ($send_confirmation_email && !$self->h->{anonymous_report}) {
+ $self->h->{sent_confirm_id_ref} = $self->report->$send_confirmation_email;
+ $self->_send_report_sent_email;
+ }
+ $self->log("Send successful");
+ } else {
+ my @errors;
+ for my $sender ( keys %{$self->reporters} ) {
+ unless ( $self->reporters->{ $sender }->success ) {
+ push @errors, $self->reporters->{ $sender }->error;
+ }
+ }
+ $self->report->update_send_failed( join( '|', @errors ) );
+ $self->log("Send failed");
+ }
+}
+
+sub _send_report_sent_email {
+ my $self = shift;
+
+ # Don't send 'report sent' text
+ return unless $self->report->user->email_verified;
+
+ my $contributed_as = $self->report->get_extra_metadata('contributed_as') || '';
+ return if $contributed_as eq 'body' || $contributed_as eq 'anonymous_user';
+
+ FixMyStreet::Email::send_cron(
+ $self->report->result_source->schema,
+ 'confirm_report_sent.txt',
+ $self->h,
+ {
+ To => $self->report->user->email,
+ },
+ undef,
+ $self->nomail,
+ $self->cobrand,
+ $self->report->lang,
+ );
+}
+
+sub log {
+ my ($self, $msg) = @_;
+ return unless $self->verbose;
+ STDERR->print("[fmsd] [" . $self->report->id . "] $msg\n");
+}
+
+1;
diff --git a/perllib/FixMyStreet/Roles/BoroughEmails.pm b/perllib/FixMyStreet/Roles/BoroughEmails.pm
new file mode 100644
index 000000000..ba941f64f
--- /dev/null
+++ b/perllib/FixMyStreet/Roles/BoroughEmails.pm
@@ -0,0 +1,68 @@
+package FixMyStreet::Roles::BoroughEmails;
+use Moo::Role;
+
+=head1 NAME
+
+FixMyStreet::Roles::BoroughEmails - role for directing reports according to the
+borough_email_addresses COBRAND_FEATURE
+
+=cut
+
+=head2 munge_sendreport_params
+
+TfL want reports made in certain categories sent to different email addresses
+depending on what London Borough they were made in. To achieve this we have
+some config in COBRAND_FEATURES that specifies what address to direct reports
+to based on the MapIt area IDs it's in.
+
+Contacts that use this technique have a short code in their email field,
+which is looked up in the `borough_email_addresses` hash.
+
+For example, if you wanted Pothole reports in Bromley and Barnet to be sent to
+one email address, and Pothole reports in Hounslow to be sent to another,
+create a contact with category = "Potholes" and email = "BOROUGHPOTHOLES" and
+use the following config in general.yml:
+
+COBRAND_FEATURES:
+ borough_email_addresses:
+ tfl:
+ BOROUGHPOTHOLES:
+ - email: bromleybarnetpotholes@example.org
+ areas:
+ - 2482 # Bromley
+ - 2489 # Barnet
+ - email: hounslowpotholes@example.org
+ areas:
+ - 2483 # Hounslow
+
+=cut
+
+sub munge_sendreport_params {
+ my ($self, $row, $h, $params) = @_;
+
+ my $addresses = $self->feature('borough_email_addresses');
+ return unless $addresses;
+
+ my @report_areas = grep { $_ } split ',', $row->areas;
+
+ my $to = $params->{To};
+ my @munged_to = ();
+ for my $recip ( @$to ) {
+ my ($email, $name) = @$recip;
+ if (my $teams = $addresses->{$email}) {
+ for my $team (@$teams) {
+ my %team_area_ids = map { $_ => 1 } @{ $team->{areas} };
+ if ( grep { $team_area_ids{$_} } @report_areas ) {
+ $recip = [
+ $team->{email},
+ $name
+ ];
+ }
+ }
+ }
+ push @munged_to, $recip;
+ }
+ $params->{To} = \@munged_to;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Roles/ConfirmOpen311.pm b/perllib/FixMyStreet/Roles/ConfirmOpen311.pm
new file mode 100644
index 000000000..0845105f1
--- /dev/null
+++ b/perllib/FixMyStreet/Roles/ConfirmOpen311.pm
@@ -0,0 +1,43 @@
+package FixMyStreet::Roles::ConfirmOpen311;
+use Moo::Role;
+
+=head1 NAME
+
+FixMyStreet::Roles::ConfirmOpen311 - role for adding various Open311 things specific to Confirm
+
+=cut
+
+sub open311_config {
+ my ($self, $row, $h, $params) = @_;
+
+ $params->{multi_photos} = 1;
+}
+
+sub open311_extra_data {
+ my ($self, $row, $h, $extra) = @_;
+
+ my $open311_only = [
+ { name => 'report_url',
+ value => $h->{url} },
+ { name => 'title',
+ value => $row->title },
+ { name => 'description',
+ value => $row->detail },
+ ];
+
+ # Reports made via FMS.com or the app probably won't have a USRN
+ # value because we don't display the adopted highways layer on those
+ # frontends. Instead we'll look up the closest asset from the WFS
+ # service at the point we're sending the report over Open311.
+ if (!$row->get_extra_field_value('site_code')) {
+ if (my $site_code = $self->lookup_site_code($row)) {
+ push @$extra,
+ { name => 'site_code',
+ value => $site_code };
+ }
+ }
+
+ return $open311_only;
+}
+
+1;
diff --git a/perllib/FixMyStreet/Roles/ConfirmValidation.pm b/perllib/FixMyStreet/Roles/ConfirmValidation.pm
index 776230287..27592b33c 100644
--- a/perllib/FixMyStreet/Roles/ConfirmValidation.pm
+++ b/perllib/FixMyStreet/Roles/ConfirmValidation.pm
@@ -17,6 +17,8 @@ Confirm field lengths.
has max_report_length => ( is => 'ro', default => 2000 );
+has max_title_length => ( is => 'ro', default => 0 );
+
sub report_validation {
my ($self, $report, $errors) = @_;
@@ -24,10 +26,14 @@ sub report_validation {
$errors->{name} = sprintf( _('Names are limited to %d characters in length.'), 50 );
}
- if ( length( $report->user->phone ) > 20 ) {
+ if ( $report->user->phone && length( $report->user->phone ) > 20 ) {
$errors->{phone} = sprintf( _('Phone numbers are limited to %s characters in length.'), 20 );
}
+ if ( $self->max_title_length > 0 && length( $report->title ) > $self->max_title_length ) {
+ $errors->{title} = sprintf( _('Summaries are limited to %d characters in length. Please shorten your summary'), 50 );
+ }
+
if ( length( $report->detail ) > $self->max_report_length ) {
$errors->{detail} = sprintf( _('Reports are limited to %s characters in length. Please shorten your report'), $self->max_report_length );
}
diff --git a/perllib/FixMyStreet/Roles/ContactExtra.pm b/perllib/FixMyStreet/Roles/ContactExtra.pm
index 55c055d99..e78d9b53f 100644
--- a/perllib/FixMyStreet/Roles/ContactExtra.pm
+++ b/perllib/FixMyStreet/Roles/ContactExtra.pm
@@ -25,8 +25,15 @@ sub for_bodies {
}
sub by_categories {
- my ($rs, $area_id, @contacts) = @_;
- my %body_ids = map { $_->body_id => 1 } FixMyStreet::DB->resultset('BodyArea')->search({ area_id => $area_id });
+ my ($rs, $contacts, %params) = @_;
+
+ my %body_ids = ();
+ if ( $params{body_id} ) {
+ %body_ids = ( $params{body_id} => 1 );
+ } else {
+ %body_ids = map { $_->body_id => 1 } FixMyStreet::DB->resultset('BodyArea')->search({ area_id => $params{area_id} });
+ }
+ my @contacts = @$contacts;
my @body_ids = keys %body_ids;
my %extras = ();
my @results = $rs->for_bodies(\@body_ids, undef);
diff --git a/perllib/FixMyStreet/Roles/Extra.pm b/perllib/FixMyStreet/Roles/Extra.pm
index 445f6d91c..530064b99 100644
--- a/perllib/FixMyStreet/Roles/Extra.pm
+++ b/perllib/FixMyStreet/Roles/Extra.pm
@@ -135,6 +135,58 @@ sub push_extra_fields {
$self->extra({ %$extra, $META_FIELD => [ @$existing, @fields ] });
}
+=head2 update_extra_field
+
+ $problem->update_extra_field( { ... } );
+
+Given an extra field, will replace one with the same code in the
+existing list of fields, or add to the end if not present.
+Returns true if it was already present, false if newly added.
+
+=cut
+
+sub update_extra_field {
+ my ($self, $field) = @_;
+
+ # Can operate on list that uses code (Contact) or name (Problem),
+ # but make sure we have one of them
+ my $attr;
+ $attr = 'code' if $field->{code};
+ $attr = 'name' if $field->{name};
+ die unless $attr;
+
+ my $existing = $self->get_extra_fields;
+ my $found;
+ foreach (@$existing) {
+ if ($_->{$attr} eq $field->{$attr}) {
+ $_ = $field;
+ $found = 1;
+ }
+ }
+ if (!$found) {
+ push @$existing, $field;
+ }
+
+ $self->set_extra_fields(@$existing);
+ return $found;
+}
+
+=head2 remove_extra_field
+
+ $problem->remove_extra_field( $code );
+
+Given an extra field code, will remove it from the list of fields.
+
+=cut
+
+sub remove_extra_field {
+ my ($self, $code) = @_;
+
+ my @fields = @{ $self->get_extra_fields() };
+ @fields = grep { ($_->{code} || $_->{name}) ne $code } @fields;
+ $self->set_extra_fields(@fields);
+}
+
=head1 HELPER METHODS
For internal use mostly.
@@ -191,4 +243,24 @@ sub get_extra_field_value {
return $field->{value};
}
+=head2 get_extra_field
+
+ my $field = $problem->get_extra_field(name => 'field_name');
+
+Return a field stored in `_fields` in extra, or undefined if it's not present.
+Can use either `name` or `code` to identify the field.
+
+=cut
+
+sub get_extra_field {
+ my ($self, %opts) = @_;
+
+ my @fields = @{ $self->get_extra_fields() };
+
+ my $comparison = $opts{code} ? 'code' : 'name';
+
+ my ($field) = grep { $_->{$comparison} && $_->{$comparison} eq $opts{$comparison} } @fields;
+ return $field;
+}
+
1;
diff --git a/perllib/FixMyStreet/Roles/PhotoSet.pm b/perllib/FixMyStreet/Roles/PhotoSet.pm
index 4a40ef3f9..3d0027f8c 100644
--- a/perllib/FixMyStreet/Roles/PhotoSet.pm
+++ b/perllib/FixMyStreet/Roles/PhotoSet.pm
@@ -31,6 +31,11 @@ sub get_first_image_fp {
return $self->get_photoset->get_image_data( num => 0, size => 'fp' );
}
+sub get_first_image_og {
+ my ($self) = @_;
+ return $self->get_photoset->get_image_data( num => 0, size => 'og' );
+}
+
sub photos {
my $self = shift;
my $photoset = $self->get_photoset;
@@ -38,15 +43,18 @@ sub photos {
my $id = $self->id;
my $typ = $self->result_source->name eq 'comment' ? 'c/' : '';
+ my $non_public = $self->result_source->name eq 'comment'
+ ? $self->problem->non_public : $self->non_public;
+
my @photos = map {
my $cachebust = substr($_, 0, 8);
# Some Varnish configurations (e.g. on mySociety infra) strip cookies from
# images, which means image requests will be redirected to the login page
- # if LOGIN_REQUIRED is set. To stop this happening, Varnish should be
+ # if e.g. LOGIN_REQUIRED is set. To stop this happening, Varnish should be
# configured to not strip cookies if the cookie_passthrough param is
# present, which this line ensures will be if LOGIN_REQUIRED is set.
my $extra = '';
- if (FixMyStreet->config('LOGIN_REQUIRED')) {
+ if (FixMyStreet->config('LOGIN_REQUIRED') || $non_public) {
$cachebust .= '&cookie_passthrough=1';
$extra = '?cookie_passthrough=1';
}
@@ -59,6 +67,7 @@ sub photos {
url_full => "/photo/$typ$id.$i.full.$format?$cachebust",
url_tn => "/photo/$typ$id.$i.tn.$format?$cachebust",
url_fp => "/photo/$typ$id.$i.fp.$format?$cachebust",
+ url_og => "/photo/$typ$id.$i.og.$format?$cachebust",
idx => $i++,
}
} $photoset->all_ids;
diff --git a/perllib/FixMyStreet/Roles/Translatable.pm b/perllib/FixMyStreet/Roles/Translatable.pm
index d39d97bf8..0c84bbf0f 100644
--- a/perllib/FixMyStreet/Roles/Translatable.pm
+++ b/perllib/FixMyStreet/Roles/Translatable.pm
@@ -40,19 +40,6 @@ sub _translate {
my $translated = $self->translated->{$col}{$lang};
return $translated if $translated;
- # Deal with the fact problem table has denormalized copy of category string
- if ($table eq 'problem' && $col eq 'category') {
- my $body_id = $self->bodies_str_ids->[0];
- return $fallback unless $body_id && $body_id =~ /^[0-9]+$/;
- my $contact = $schema->resultset("Contact")->find( {
- body_id => $body_id,
- category => $fallback,
- } );
- return $fallback unless $contact; # Shouldn't happen, but some tests
- $table = 'contact';
- $id = $contact->id;
- }
-
if (ref $schema) {
my $translation = $schema->resultset('Translation')->find({
lang => $lang,
diff --git a/perllib/FixMyStreet/Script/Alerts.pm b/perllib/FixMyStreet/Script/Alerts.pm
index 55f4b3db5..cb1f022fa 100644
--- a/perllib/FixMyStreet/Script/Alerts.pm
+++ b/perllib/FixMyStreet/Script/Alerts.pm
@@ -40,6 +40,7 @@ sub send() {
$item_table.confirmed as item_confirmed,
$item_table.photo as item_photo,
$item_table.problem_state as item_problem_state,
+ $item_table.cobrand as item_cobrand,
$head_table.*
from alert, $item_table, $head_table
where alert.parameter::integer = $head_table.id
@@ -47,6 +48,7 @@ sub send() {
";
} else {
$query .= " $item_table.*,
+ $item_table.cobrand as item_cobrand,
$item_table.id as item_id
from alert, $item_table
where 1 = 1";
@@ -84,6 +86,8 @@ sub send() {
next unless FixMyStreet::DB::Result::Problem::visible_states()->{$row->{state}};
+ next if $row->{alert_cobrand} ne 'tfl' && $row->{item_cobrand} eq 'tfl';
+
$schema->resultset('AlertSent')->create( {
alert_id => $row->{alert_id},
parameter => $row->{item_id},
@@ -97,7 +101,7 @@ sub send() {
!( $last_problem_state eq '' && $row->{item_problem_state} eq 'confirmed' ) &&
$last_problem_state ne $row->{item_problem_state}
) {
- my $state = FixMyStreet::DB->resultset("State")->display($row->{item_problem_state}, 1, $cobrand);
+ my $state = FixMyStreet::DB->resultset("State")->display($row->{item_problem_state}, 1, $cobrand->moniker);
my $update = _('State changed to:') . ' ' . $state;
$row->{item_text} = $row->{item_text} ? $row->{item_text} . "\n\n" . $update :
@@ -121,6 +125,13 @@ sub send() {
$data{state_message} = _("This report is currently marked as open.");
}
+ if (!$data{alert_user_id}) {
+ if ($ref eq 'new_updates') {
+ # Get a report object for its photo and static map
+ $data{report} = $schema->resultset('Problem')->find({ id => $row->{id} });
+ }
+ }
+
my $url = $cobrand->base_url_for_report($row);
# this is currently only for new_updates
if (defined($row->{item_text})) {
@@ -138,6 +149,12 @@ sub send() {
}
} );
$data{problem_url} = $url . "/R/" . $token_obj->token;
+
+ # Also record timestamp on report if it's an update about being fixed...
+ if (FixMyStreet::DB::Result::Problem::fixed_states()->{$row->{state}} || FixMyStreet::DB::Result::Problem::closed_states()->{$row->{state}}) {
+ $data{report}->set_extra_metadata_if_undefined('closure_alert_sent_at', time());
+ $data{report}->update;
+ }
} else {
$data{problem_url} = $url . "/report/" . $row->{id};
}
@@ -181,10 +198,6 @@ sub send() {
if (!$data{alert_user_id}) {
%data = (%data, %$row);
- if ($ref eq 'new_updates') {
- # Get a report object for its photo and static map
- $data{report} = $schema->resultset('Problem')->find({ id => $row->{id} });
- }
if ($ref eq 'area_problems') {
my $va_info = FixMyStreet::MapIt::call('area', $row->{alert_parameter});
$data{area_name} = $va_info->{name};
@@ -239,7 +252,7 @@ sub send() {
cobrand_data => $alert->cobrand_data,
schema => $schema,
);
- my $q = "select problem.id, problem.bodies_str, problem.postcode, problem.geocode, problem.confirmed,
+ my $q = "select problem.id, problem.bodies_str, problem.postcode, problem.geocode, problem.confirmed, problem.cobrand,
problem.title, problem.detail, problem.photo from problem_find_nearby(?, ?, ?) as nearby, problem, users
where nearby.problem_id = problem.id
and problem.user_id = users.id
@@ -252,6 +265,8 @@ sub send() {
$q = FixMyStreet::DB->schema->storage->dbh->prepare($q);
$q->execute($latitude, $longitude, $d, $alert->whensubscribed, $alert->id, $alert->user->email);
while (my $row = $q->fetchrow_hashref) {
+ next if $alert->cobrand ne 'tfl' && $row->{cobrand} eq 'tfl';
+
$schema->resultset('AlertSent')->create( {
alert_id => $alert->id,
parameter => $row->{id},
@@ -333,7 +348,6 @@ sub _get_address_from_geocode {
my $geocode = shift;
return '' unless defined $geocode;
- utf8::encode($geocode) if utf8::is_utf8($geocode);
my $h = new IO::String($geocode);
my $data = RABX::wire_rd($h);
diff --git a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm
index 0c938682d..7ba763515 100644
--- a/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm
+++ b/perllib/FixMyStreet/Script/ArchiveOldEnquiries.pm
@@ -11,6 +11,7 @@ use FixMyStreet::Email;
my $opts = {
commit => 0,
+ closed_state => 'closed',
};
sub query {
@@ -161,10 +162,10 @@ sub close_problems {
mark_fixed => 0,
anonymous => 0,
state => 'confirmed',
- problem_state => 'closed',
+ problem_state => $opts->{closed_state},
extra => $extra,
} );
- $problem->update({ state => 'closed', send_questionnaire => 0 });
+ $problem->update({ state => $opts->{closed_state}, send_questionnaire => 0 });
next if $opts->{retain_alerts};
diff --git a/perllib/FixMyStreet/Script/CreateSuperuser.pm b/perllib/FixMyStreet/Script/CreateSuperuser.pm
index 69d165abb..cbbea577a 100644
--- a/perllib/FixMyStreet/Script/CreateSuperuser.pm
+++ b/perllib/FixMyStreet/Script/CreateSuperuser.pm
@@ -7,19 +7,27 @@ use FixMyStreet;
use FixMyStreet::DB;
sub createsuperuser {
- die "Specify a single email address and optionally password to create a superuser or grant superuser status to." if (@ARGV < 1 || @ARGV > 2);
+ my ($email, $password) = @_;
- my $user = FixMyStreet::DB->resultset('User')->find_or_new({ email => $ARGV[0] });
+ unless ($email) {
+ warn "Specify a single email address and optionally password to create a superuser or grant superuser status to.\n";
+ return 1;
+ }
+
+ my $user = FixMyStreet::DB->resultset('User')->find_or_new({ email => $email });
if ( !$user->in_storage ) {
- die "Specify a password for this new user." if (@ARGV < 2);
- $user->password($ARGV[1]);
+ unless ($password) {
+ warn "Specify a password for this new user.\n";
+ return 1;
+ }
+ $user->password($password);
$user->is_superuser(1);
$user->insert;
} else {
$user->update({ is_superuser => 1 });
}
print $user->email . " is now a superuser.\n";
+ return 0;
}
-
-1; \ No newline at end of file
+1;
diff --git a/perllib/FixMyStreet/Script/Inactive.pm b/perllib/FixMyStreet/Script/Inactive.pm
index 0468d2a52..8dd524ce1 100644
--- a/perllib/FixMyStreet/Script/Inactive.pm
+++ b/perllib/FixMyStreet/Script/Inactive.pm
@@ -12,17 +12,23 @@ use FixMyStreet::Email;
has anonymize => ( is => 'ro' );
has close => ( is => 'ro' );
+has delete => ( is => 'ro' );
has email => ( is => 'ro' );
has verbose => ( is => 'ro' );
has dry_run => ( is => 'ro' );
+has cobrand => (
+ is => 'ro',
+ coerce => sub { FixMyStreet::Cobrand->get_class_for_moniker($_[0])->new },
+);
+
sub BUILDARGS {
my ($cls, %args) = @_;
$args{dry_run} = delete $args{'dry-run'};
return \%args;
}
-has cobrand => (
+has base_cobrand => (
is => 'lazy',
default => sub {
my $base_url = FixMyStreet->config('BASE_URL');
@@ -55,6 +61,7 @@ sub reports {
say "DRY RUN" if $self->dry_run;
$self->anonymize_reports if $self->anonymize;
+ $self->delete_reports if $self->delete;
$self->close_updates if $self->close;
}
@@ -66,6 +73,7 @@ sub close_updates {
state => [ FixMyStreet::DB::Result::Problem->closed_states(), FixMyStreet::DB::Result::Problem->fixed_states() ],
extra => [ undef, { -not_like => '%closed_updates%' } ],
});
+ $problems = $problems->search({ cobrand => $self->cobrand->moniker }) if $self->cobrand;
while (my $problem = $problems->next) {
say "Closing updates on problem #" . $problem->id if $self->verbose;
@@ -75,18 +83,28 @@ sub close_updates {
}
}
-sub anonymize_reports {
- my $self = shift;
-
- # Need to look though them all each time, in case any new updates/alerts
+sub _relevant_reports {
+ my ($self, $time) = @_;
my $problems = FixMyStreet::DB->resultset("Problem")->search({
- lastupdate => { '<', interval($self->anonymize) },
+ lastupdate => { '<', interval($time) },
state => [
FixMyStreet::DB::Result::Problem->closed_states(),
FixMyStreet::DB::Result::Problem->fixed_states(),
FixMyStreet::DB::Result::Problem->hidden_states(),
],
});
+ if ($self->cobrand) {
+ $problems = $problems->search({ cobrand => $self->cobrand->moniker });
+ $problems = $self->cobrand->call_hook(inactive_reports_filter => $time, $problems) || $problems;
+ }
+ return $problems;
+}
+
+sub anonymize_reports {
+ my $self = shift;
+
+ # Need to look though them all each time, in case any new updates/alerts
+ my $problems = $self->_relevant_reports($self->anonymize);
while (my $problem = $problems->next) {
say "Anonymizing problem #" . $problem->id if $self->verbose;
@@ -110,10 +128,8 @@ sub anonymize_reports {
});
# Remove alerts - could just delete, but of interest how many there were, perhaps?
- FixMyStreet::DB->resultset('Alert')->search({
+ $problem->alerts->search({
user_id => { '!=' => $self->anonymous_user->id },
- alert_type => 'new_updates',
- parameter => $problem->id,
})->update({
user_id => $self->anonymous_user->id,
whendisabled => \'current_timestamp',
@@ -121,11 +137,30 @@ sub anonymize_reports {
}
}
+sub delete_reports {
+ my $self = shift;
+
+ my $problems = $self->_relevant_reports($self->delete);
+
+ while (my $problem = $problems->next) {
+ say "Deleting associated data of problem #" . $problem->id if $self->verbose;
+ next if $self->dry_run;
+
+ $problem->comments->delete;
+ $problem->questionnaires->delete;
+ $problem->alerts->delete;
+ }
+ say "Deleting all matching problems" if $self->verbose;
+ return if $self->dry_run;
+ $problems->delete;
+}
+
sub anonymize_users {
my $self = shift;
my $users = FixMyStreet::DB->resultset("User")->search({
last_active => { '<', interval($self->anonymize) },
+ email => { -not_like => 'removed-%@' . FixMyStreet->config('EMAIL_DOMAIN') },
});
while (my $user = $users->next) {
@@ -154,10 +189,10 @@ sub email_inactive_users {
email_from => $self->email,
anonymize_from => $self->anonymize,
user => $user,
- url => $self->cobrand->base_url_with_lang . '/my',
+ url => $self->base_cobrand->base_url_with_lang . '/my',
},
- { To => [ $user->email, $user->name ] },
- undef, 0, $self->cobrand,
+ { To => [ [ $user->email, $user->name ] ] },
+ undef, 0, $self->base_cobrand,
);
$user->set_extra_metadata('inactive_email_sent', 1);
diff --git a/perllib/FixMyStreet/Script/Questionnaires.pm b/perllib/FixMyStreet/Script/Questionnaires.pm
index aab4b9b75..5db66ff7c 100644
--- a/perllib/FixMyStreet/Script/Questionnaires.pm
+++ b/perllib/FixMyStreet/Script/Questionnaires.pm
@@ -33,8 +33,8 @@ sub send_questionnaires_period {
};
$q_params->{'-or'} = [
- '(select max(whensent) from questionnaire where me.id=problem_id)' => undef,
- '(select max(whenanswered) from questionnaire where me.id=problem_id)' => { '<', \"current_timestamp - '$period'::interval" }
+ \'(select max(whensent) from questionnaire where me.id=problem_id) IS NULL',
+ \"(select max(whenanswered) from questionnaire where me.id=problem_id) < current_timestamp - '$period'::interval",
];
my $unsent = FixMyStreet::DB->resultset('Problem')->search( $q_params, {
diff --git a/perllib/FixMyStreet/Script/Reports.pm b/perllib/FixMyStreet/Script/Reports.pm
index ecd461cd9..3d5afe216 100644
--- a/perllib/FixMyStreet/Script/Reports.pm
+++ b/perllib/FixMyStreet/Script/Reports.pm
@@ -1,340 +1,143 @@
package FixMyStreet::Script::Reports;
-use strict;
-use warnings;
-
+use Moo;
use CronFns;
-use DateTime::Format::Pg;
-
-use Utils;
-use Utils::OpenStreetMap;
-
use FixMyStreet;
-use FixMyStreet::Cobrand;
use FixMyStreet::DB;
-use FixMyStreet::Email;
-use FixMyStreet::Map;
-use FixMyStreet::SendReport;
-
-sub send(;$) {
- my ($site_override) = @_;
- my $rs = FixMyStreet::DB->resultset('Problem');
-
- # Set up site, language etc.
- my ($verbose, $nomail, $debug_mode) = CronFns::options();
- my $test_data;
-
- my $base_url = FixMyStreet->config('BASE_URL');
- my $site = $site_override || CronFns::site($base_url);
-
- my $states = [ FixMyStreet::DB::Result::Problem::open_states() ];
- $states = [ 'submitted', 'confirmed', 'in progress', 'feedback pending', 'external', 'wish' ] if $site eq 'zurich';
- my $unsent = $rs->search( {
- state => $states,
- whensent => undef,
- bodies_str => { '!=', undef },
- } );
- my (%notgot, %note);
+use FixMyStreet::Queue::Item::Report;
- my $send_report = FixMyStreet::SendReport->new();
- my $senders = $send_report->get_senders;
+has verbose => ( is => 'ro' );
- my $debug_unsent_count = 0;
- debug_print("starting to loop through unsent problem reports...") if $debug_mode;
- while (my $row = $unsent->next) {
+has unconfirmed_data => ( is => 'ro', default => sub { {} } );
+has test_data => ( is => 'ro', default => sub { {} } );
- my $cobrand = $row->get_cobrand_logged;
- FixMyStreet::DB->schema->cobrand($cobrand);
-
- if ($debug_mode) {
- $debug_unsent_count++;
- print "\n";
- debug_print("state=" . $row->state . ", bodies_str=" . $row->bodies_str . ($row->cobrand? ", cobrand=" . $row->cobrand : ""), $row->id);
- }
-
- # Cobranded and non-cobranded messages can share a database. In this case, the conf file
- # should specify a vhost to send the reports for each cobrand, so that they don't get sent
- # more than once if there are multiple vhosts running off the same database. The email_host
- # call checks if this is the host that sends mail for this cobrand.
- if (! $cobrand->email_host()) {
- debug_print("skipping because this host does not send reports for cobrand " . $cobrand->moniker, $row->id) if $debug_mode;
- next;
- }
+# Static method, used by send-reports cron script and tests.
+# Creates a manager object from provided data and processes it.
+sub send {
+ my ($verbose, $nomail, $debug) = @_;
- $cobrand->set_lang_and_domain($row->lang, 1);
- FixMyStreet::Map::set_map_class($cobrand->map_type);
- if ( $row->is_from_abuser) {
- $row->update( { state => 'hidden' } );
- debug_print("hiding because its sender is flagged as an abuser", $row->id) if $debug_mode;
- next;
- } elsif ( $row->title =~ /app store test/i ) {
- $row->update( { state => 'hidden' } );
- debug_print("hiding because it is an app store test message", $row->id) if $debug_mode;
- next;
- }
-
- # Template variables for the email
- my $email_base_url = $cobrand->base_url_for_report($row);
- my %h = map { $_ => $row->$_ } qw/id title detail name category latitude longitude used_map/;
- $h{report} = $row;
- $h{cobrand} = $cobrand;
- map { $h{$_} = $row->user->$_ || '' } qw/email phone/;
- $h{confirmed} = DateTime::Format::Pg->format_datetime( $row->confirmed->truncate (to => 'second' ) )
- if $row->confirmed;
-
- $h{query} = $row->postcode;
- $h{url} = $email_base_url . $row->url;
- $h{admin_url} = $row->admin_url($cobrand);
- if ($row->photo) {
- $h{has_photo} = _("This web page also contains a photo of the problem, provided by the user.") . "\n\n";
- $h{image_url} = $email_base_url . $row->photos->[0]->{url_full};
- my @all_images = map { $email_base_url . $_->{url_full} } @{ $row->photos };
- $h{all_image_urls} = \@all_images;
- } else {
- $h{has_photo} = '';
- $h{image_url} = '';
- }
- $h{fuzzy} = $row->used_map ? _('To view a map of the precise location of this issue')
- : _('The user could not locate the problem on a map, but to see the area around the location they entered');
- $h{closest_address} = '';
-
- $h{osm_url} = Utils::OpenStreetMap::short_url($h{latitude}, $h{longitude});
- if ( $row->used_map ) {
- $h{closest_address} = $cobrand->find_closest($row);
- $h{osm_url} .= '?m';
- }
-
- if ( $cobrand->allow_anonymous_reports &&
- $row->user->email eq $cobrand->anonymous_account->{'email'}
- ) {
- $h{anonymous_report} = 1;
- }
-
- $cobrand->call_hook(process_additional_metadata_for_email => $row, \%h);
-
- my $bodies = FixMyStreet::DB->resultset('Body')->search(
- { id => $row->bodies_str_ids },
- { order_by => 'name' },
- );
-
- my $missing;
- if ($row->bodies_missing) {
- my @missing = FixMyStreet::DB->resultset("Body")->search(
- { id => [ split /,/, $row->bodies_missing ] },
- { order_by => 'name' }
- )->get_column('name')->all;
- $missing = join(' / ', @missing) if @missing;
- }
-
- my $send_confirmation_email = $cobrand->report_sent_confirmation_email;
-
- my @dear;
- my %reporters = ();
- my $skip = 0;
- while (my $body = $bodies->next) {
- # See if this body wants confirmation email (in case report made on national site, for example)
- if (my $cobrand_body = $body->get_cobrand_handler) {
- if (my $id_ref = $cobrand_body->report_sent_confirmation_email) {
- $send_confirmation_email = $id_ref;
- }
- }
-
- my $sender_info = $cobrand->get_body_sender( $body, $row->category );
- my $sender = "FixMyStreet::SendReport::" . $sender_info->{method};
-
- if ( ! exists $senders->{ $sender } ) {
- warn sprintf "No such sender [ $sender ] for body %s ( %d )", $body->name, $body->id;
- next;
- }
- $reporters{ $sender } ||= $sender->new();
+ my $manager = __PACKAGE__->new(
+ verbose => $verbose,
+ );
- my $inspection_required = $sender_info->{contact}
- ? $sender_info->{contact}->get_extra_metadata('inspection_required')
- : undef;
- if ( $inspection_required ) {
- my $reputation_threshold = $sender_info->{contact}->get_extra_metadata('reputation_threshold') || 0;
- my $reputation_threshold_met = 0;
- if ( $reputation_threshold > 0 ) {
- my $user_reputation = $row->user->get_extra_metadata('reputation') || 0;
- $reputation_threshold_met = $user_reputation >= $reputation_threshold;
- }
- unless (
- $row->get_extra_metadata('inspected') ||
- $row->user->has_permission_to( trusted => $row->bodies_str_ids ) ||
- $reputation_threshold_met
- ) {
- $skip = 1;
- debug_print("skipped because not yet inspected", $row->id) if $debug_mode;
- }
- }
+ my $params = construct_query($debug);
+ my $db = FixMyStreet::DB->schema->storage;
- if ( $reporters{ $sender }->should_skip( $row, $debug_mode ) ) {
- $skip = 1;
- debug_print("skipped by sender " . $sender_info->{method} . " (might be due to previous failed attempts?)", $row->id) if $debug_mode;
- } else {
- debug_print("OK, adding recipient body " . $body->id . ":" . $body->name . ", " . $sender_info->{method}, $row->id) if $debug_mode;
- push @dear, $body->name;
- $reporters{ $sender }->add_body( $body, $sender_info->{config} );
- }
+ $db->txn_do(sub {
+ my $unsent = FixMyStreet::DB->resultset('Problem')->search($params, {
+ for => \'UPDATE SKIP LOCKED',
+ });
- # If we are in the UK include eastings and northings
- if ( $cobrand->country eq 'GB' && !$h{easting} ) {
- ( $h{easting}, $h{northing}, $h{coordsyst} ) = $row->local_coords;
- }
+ $manager->log("starting to loop through unsent problem reports...");
+ my $unsent_count = 0;
+ while (my $row = $unsent->next) {
+ $unsent_count++;
+ my $item = FixMyStreet::Queue::Item::Report->new(
+ report => $row,
+ manager => $manager,
+ verbose => $verbose,
+ nomail => $nomail,
+ );
+ $item->process;
}
- unless ( keys %reporters ) {
- die 'Report not going anywhere for ID ' . $row->id . '!';
- }
+ $manager->end_line($unsent_count);
+ $manager->end_summary_unconfirmed;
+ });
- next if $skip;
+ return $manager->test_data;
+}
- if ($h{category} eq _('Other')) {
- $h{category_footer} = _('this type of local problem');
- } else {
- $h{category_footer} = "'" . $h{category} . "'";
- }
+sub construct_query {
+ my ($debug) = @_;
+ my $site = CronFns::site(FixMyStreet->config('BASE_URL'));
+ my $states = [ FixMyStreet::DB::Result::Problem::open_states() ];
+ $states = [ 'submitted', 'confirmed', 'in progress', 'feedback pending', 'external', 'wish' ] if $site eq 'zurich';
- $h{bodies_name} = join(_(' and '), @dear);
- if ($h{category} eq _('Other')) {
- $h{multiple} = @dear>1 ? "[ " . _("This email has been sent to both councils covering the location of the problem, as the user did not categorise it; please ignore it if you're not the correct council to deal with the issue, or let us know what category of problem this is so we can add it to our system.") . " ]\n\n"
- : '';
- } else {
- $h{multiple} = @dear>1 ? "[ " . _("This email has been sent to several councils covering the location of the problem, as the category selected is provided for all of them; please ignore it if you're not the correct council to deal with the issue.") . " ]\n\n"
- : '';
- }
- $h{missing} = '';
- if ($missing) {
- $h{missing} = '[ '
- . sprintf(_('We realise this problem might be the responsibility of %s; however, we don\'t currently have any contact details for them. If you know of an appropriate contact address, please do get in touch.'), $missing)
- . " ]\n\n";
- }
+ # Devolved Noop categories (unlikely to be any, but still)
+ my @noop_params;
+ my $noop_cats = FixMyStreet::DB->resultset('Contact')->search({
+ 'body.can_be_devolved' => 1,
+ 'me.send_method' => 'Noop'
+ }, { join => 'body' });
+ while (my $cat = $noop_cats->next) {
+ push @noop_params, [
+ \[ "NOT regexp_split_to_array(bodies_str, ',') && ?", [ {} => [ $cat->body_id ] ] ],
+ category => { '!=' => $cat->category } ];
+ }
- if (FixMyStreet->staging_flag('send_reports', 0)) {
- # on a staging server send emails to ourselves rather than the bodies
- %reporters = map { $_ => $reporters{$_} } grep { /FixMyStreet::SendReport::Email/ } keys %reporters;
- unless (%reporters) {
- %reporters = ( 'FixMyStreet::SendReport::Email' => FixMyStreet::SendReport::Email->new() );
- }
- }
+ # Noop bodies
+ my @noop_bodies = FixMyStreet::DB->resultset('Body')->search({ send_method => 'Noop' })->all;
+ @noop_bodies = map { $_->id } @noop_bodies;
+ push @noop_params, \[ "NOT regexp_split_to_array(bodies_str, ',') && ?", [ {} => \@noop_bodies ] ];
- # Multiply results together, so one success counts as a success.
- my $result = -1;
+ my $params = {
+ state => $states,
+ whensent => undef,
+ bodies_str => { '!=', undef },
+ -and => \@noop_params,
+ };
+ if (!$debug) {
+ $params->{'-or'} = [
+ send_fail_count => 0,
+ { send_fail_count => 1, send_fail_timestamp => { '<', \"current_timestamp - '5 minutes'::interval" } },
+ { send_fail_timestamp => { '<', \"current_timestamp - '30 minutes'::interval" } },
+ ];
+ }
- my @methods;
- for my $sender ( keys %reporters ) {
- debug_print("sending using " . $sender, $row->id) if $debug_mode;
- $sender = $reporters{$sender};
- my $res = $sender->send( $row, \%h );
- $result *= $res;
- push @methods, $sender if !$res;
- if ( $sender->unconfirmed_counts) {
- foreach my $e (keys %{ $sender->unconfirmed_counts } ) {
- foreach my $c (keys %{ $sender->unconfirmed_counts->{$e} }) {
- $notgot{$e}{$c} += $sender->unconfirmed_counts->{$e}{$c};
- }
- }
- %note = (%note, %{ $sender->unconfirmed_notes });
- }
- $test_data->{test_req_used} = $sender->open311_test_req_used
- if FixMyStreet->test_mode && $sender->can('open311_test_req_used');
- }
+ return $params;
+}
- # Add the send methods now because e.g. Open311
- # send() calls $row->discard_changes
- foreach (@methods) {
- $row->add_send_method($_);
- }
+sub end_line {
+ my ($self, $unsent_count) = @_;
+ return unless $self->verbose;
- unless ($result) {
- $row->update( {
- whensent => \'current_timestamp',
- lastupdate => \'current_timestamp',
- } );
- if ($send_confirmation_email && !$h{anonymous_report}) {
- $h{sent_confirm_id_ref} = $row->$send_confirmation_email;
- _send_report_sent_email( $row, \%h, $nomail, $cobrand );
- }
- debug_print("send successful: OK", $row->id) if $debug_mode;
- } else {
- my @errors;
- for my $sender ( keys %reporters ) {
- unless ( $reporters{ $sender }->success ) {
- push @errors, $reporters{ $sender }->error;
- }
- }
- $row->update_send_failed( join( '|', @errors ) );
- debug_print("send FAILED: " . join( '|', @errors ), $row->id) if $debug_mode;
- }
- }
- if ($debug_mode) {
- print "\n";
- if ($debug_unsent_count) {
- debug_print("processed all unsent reports (total: $debug_unsent_count)");
- } else {
- debug_print("no unsent reports were found (must have whensent=null and suitable bodies_str & state) -- nothing to send");
- }
+ if ($unsent_count) {
+ $self->log("processed all unsent reports (total: $unsent_count)");
+ } else {
+ $self->log("no unsent reports were found (must have whensent=null and suitable bodies_str & state) -- nothing to send");
}
+}
- if ($verbose || $debug_mode) {
- print "Council email addresses that need checking:\n" if keys %notgot;
- foreach my $e (keys %notgot) {
- foreach my $c (keys %{$notgot{$e}}) {
- print " " . $notgot{$e}{$c} . " problem, to $e category $c (" . $note{$e}{$c}. ")\n";
- }
- }
- my $sending_errors = '';
- my $unsent = $rs->search( {
- state => [ FixMyStreet::DB::Result::Problem::open_states() ],
- whensent => undef,
- bodies_str => { '!=', undef },
- send_fail_count => { '>', 0 }
- } );
- while (my $row = $unsent->next) {
- my $base_url = FixMyStreet->config('BASE_URL');
- $sending_errors .= "\n" . '=' x 80 . "\n\n" . "* " . $base_url . "/report/" . $row->id . ", failed "
- . $row->send_fail_count . " times, last at " . $row->send_fail_timestamp
- . ", reason " . $row->send_fail_reason . "\n";
- }
- if ($sending_errors) {
- print "The following reports had problems sending:\n$sending_errors";
+sub end_summary_unconfirmed {
+ my $self = shift;
+ return unless $self->verbose;
+
+ my %unconfirmed_data = %{$self->unconfirmed_data};
+ print "Council email addresses that need checking:\n" if keys %unconfirmed_data;
+ foreach my $e (keys %unconfirmed_data) {
+ foreach my $c (keys %{$unconfirmed_data{$e}}) {
+ my $data = $unconfirmed_data{$e}{$c};
+ print " " . $data->{count} . " problem, to $e category $c (" . $data->{note} . ")\n";
}
}
-
- return $test_data;
}
-sub _send_report_sent_email {
- my $row = shift;
- my $h = shift;
- my $nomail = shift;
- my $cobrand = shift;
-
- # Don't send 'report sent' text
- return unless $row->user->email_verified;
+sub end_summary_failures {
+ my $self = shift;
- my $contributed_as = $row->get_extra_metadata('contributed_as') || '';
- return if $contributed_as eq 'body' || $contributed_as eq 'anonymous_user';
-
- FixMyStreet::Email::send_cron(
- $row->result_source->schema,
- 'confirm_report_sent.txt',
- $h,
- {
- To => $row->user->email,
- },
- undef,
- $nomail,
- $cobrand,
- $row->lang,
- );
+ my $sending_errors = '';
+ my $unsent = FixMyStreet::DB->resultset('Problem')->search( {
+ state => [ FixMyStreet::DB::Result::Problem::open_states() ],
+ whensent => undef,
+ bodies_str => { '!=', undef },
+ send_fail_count => { '>', 0 }
+ } );
+ while (my $row = $unsent->next) {
+ my $base_url = FixMyStreet->config('BASE_URL');
+ $sending_errors .= "\n" . '=' x 80 . "\n\n" . "* " . $base_url . "/report/" . $row->id . ", failed "
+ . $row->send_fail_count . " times, last at " . $row->send_fail_timestamp
+ . ", reason " . $row->send_fail_reason . "\n";
+ }
+ if ($sending_errors) {
+ print "The following reports had problems sending:\n$sending_errors";
+ }
}
-sub debug_print {
- my $msg = shift;
- my $id = shift || '';
- $id = "report $id: " if $id;
- print "[] $id$msg\n";
+sub log {
+ my ($self, $msg) = @_;
+ return unless $self->verbose;
+ STDERR->print("[fmsd] $msg\n");
}
1;
diff --git a/perllib/FixMyStreet/Script/UpdateAllReports.pm b/perllib/FixMyStreet/Script/UpdateAllReports.pm
index 33665b9da..b23ed5b6a 100755
--- a/perllib/FixMyStreet/Script/UpdateAllReports.pm
+++ b/perllib/FixMyStreet/Script/UpdateAllReports.pm
@@ -36,8 +36,8 @@ sub generate {
{
columns => [
'id', 'bodies_str', 'state', 'areas', 'cobrand', 'category',
- { duration => { extract => "epoch from current_timestamp-lastupdate" } },
- { age => { extract => "epoch from current_timestamp-$age_column" } },
+ { duration => { extract => \"epoch from current_timestamp-lastupdate" } },
+ { age => { extract => \"epoch from current_timestamp-$age_column" } },
]
}
);
diff --git a/perllib/FixMyStreet/SendReport.pm b/perllib/FixMyStreet/SendReport.pm
index db95850e6..c08a7ddbe 100644
--- a/perllib/FixMyStreet/SendReport.pm
+++ b/perllib/FixMyStreet/SendReport.pm
@@ -15,25 +15,9 @@ has 'to' => ( is => 'rw', isa => ArrayRef, default => sub { [] } );
has 'bcc' => ( is => 'rw', isa => ArrayRef, default => sub { [] } );
has 'success' => ( is => 'rw', isa => Bool, default => 0 );
has 'error' => ( is => 'rw', isa => Str, default => '' );
-has 'unconfirmed_counts' => ( 'is' => 'rw', isa => HashRef, default => sub { {} } );
-has 'unconfirmed_notes' => ( 'is' => 'rw', isa => HashRef, default => sub { {} } );
+has 'unconfirmed_data' => ( 'is' => 'rw', isa => HashRef, default => sub { {} } );
-sub should_skip {
- my $self = shift;
- my $row = shift;
- my $debug = shift;
-
- return 0 unless $row->send_fail_count;
- return 0 if $debug;
-
- my $now = DateTime->now( time_zone => FixMyStreet->local_time_zone );
- my $diff = $now - $row->send_fail_timestamp;
-
- my $backoff = $row->send_fail_count > 1 ? 30 : 5;
- return $diff->in_units( 'minutes' ) < $backoff;
-}
-
sub get_senders {
my $self = shift;
@@ -60,4 +44,21 @@ sub add_body {
$self->body_config->{ $body->id } = $config;
}
+sub fetch_category {
+ my ($self, $body, $row, $category_override) = @_;
+
+ my $contact = $row->result_source->schema->resultset("Contact")->find( {
+ body_id => $body->id,
+ category => $category_override || $row->category,
+ } );
+
+ unless ($contact) {
+ my $error = "Category " . $row->category . " does not exist for body " . $body->id . " and report " . $row->id . "\n";
+ $self->error( "Failed to send over Open311\n" ) unless $self->error;
+ $self->error( $self->error . "\n" . $error );
+ }
+
+ return $contact;
+}
+
1;
diff --git a/perllib/FixMyStreet/SendReport/Email.pm b/perllib/FixMyStreet/SendReport/Email.pm
index cd697fa0f..72cd42952 100644
--- a/perllib/FixMyStreet/SendReport/Email.pm
+++ b/perllib/FixMyStreet/SendReport/Email.pm
@@ -12,23 +12,20 @@ sub build_recipient_list {
my $all_confirmed = 1;
foreach my $body ( @{ $self->bodies } ) {
- my $contact = $row->result_source->schema->resultset("Contact")->not_deleted->find( {
- body_id => $body->id,
- category => $row->category
- } );
+ my $contact = $self->fetch_category($body, $row) or next;
my ($body_email, $state, $note) = ( $contact->email, $contact->state, $contact->note );
$body_email = swandt_contact($row->latitude, $row->longitude)
- if ($body->areas->{2427} || $body->areas->{2429}) && $body_email eq 'SPECIAL';
+ if $body->name eq 'Somerset West and Taunton Council' && $body_email eq 'SPECIAL';
unless ($state eq 'confirmed') {
$all_confirmed = 0;
$note = 'Body ' . $row->bodies_str . ' deleted'
unless $note;
$body_email = 'N/A' unless $body_email;
- $self->unconfirmed_counts->{$body_email}{$row->category}++;
- $self->unconfirmed_notes->{$body_email}{$row->category} = $note;
+ $self->unconfirmed_data->{$body_email}{$row->category}{count}++;
+ $self->unconfirmed_data->{$body_email}{$row->category}{note} = $note;
}
my @emails;
@@ -56,6 +53,15 @@ sub send_from {
return [ $row->user->email, $row->name ];
}
+sub envelope_sender {
+ my ($self, $row) = @_;
+
+ if ($row->user->email && $row->user->email_verified) {
+ return FixMyStreet::Email::unique_verp_id('report', $row->id);
+ }
+ return $row->get_cobrand_logged->do_not_reply_email;
+}
+
sub send {
my $self = shift;
my ( $row, $h ) = @_;
@@ -85,12 +91,10 @@ sub send {
$params->{Bcc} = $self->bcc if @{$self->bcc};
- my $sender;
+ my $sender = $self->envelope_sender($row);
if ($row->user->email && $row->user->email_verified) {
- $sender = FixMyStreet::Email::unique_verp_id('report', $row->id);
$params->{From} = $self->send_from( $row );
} else {
- $sender = FixMyStreet->config('DO_NOT_REPLY_EMAIL');
my $name = sprintf(_("On behalf of %s"), @{ $self->send_from($row) }[1]);
$params->{From} = [ $sender, $name ];
}
@@ -102,10 +106,14 @@ sub send {
}
my $result = FixMyStreet::Email::send_cron($row->result_source->schema,
- $self->get_template($row), $h,
+ $self->get_template($row), {
+ %$h,
+ cobrand => $cobrand, # For correct logo that uses cobrand object
+ },
$params, $sender, $nomail, $cobrand, $row->lang);
unless ($result) {
+ $row->set_extra_metadata('sent_to' => email_list($params->{To}));
$self->success(1);
} else {
$self->error( 'Failed to send email' );
@@ -114,6 +122,12 @@ sub send {
return $result;
}
+sub email_list {
+ my $list = shift;
+ my @list = map { ref $_ ? $_->[0] : $_ } @$list;
+ return \@list;
+}
+
# SW&T has different contact addresses depending upon the old district
sub swandt_contact {
my $district = _get_district_for_contact(@_);
@@ -126,7 +140,7 @@ sub swandt_contact {
sub _get_district_for_contact {
my ( $lat, $lon ) = @_;
my $district =
- FixMyStreet::MapIt::call( 'point', "4326/$lon,$lat", type => 'DIS' );
+ FixMyStreet::MapIt::call( 'point', "4326/$lon,$lat", type => 'DIS', generation => 34 );
($district) = keys %$district;
return $district;
}
diff --git a/perllib/FixMyStreet/SendReport/Email/Highways.pm b/perllib/FixMyStreet/SendReport/Email/Highways.pm
index 2a1f7b305..2bcd120d3 100644
--- a/perllib/FixMyStreet/SendReport/Email/Highways.pm
+++ b/perllib/FixMyStreet/SendReport/Email/Highways.pm
@@ -1,11 +1,25 @@
package FixMyStreet::SendReport::Email::Highways;
use Moo;
-extends 'FixMyStreet::SendReport::Email::SingleBodyOnly';
+extends 'FixMyStreet::SendReport::Email';
-has contact => (
- is => 'ro',
- default => 'Pothole'
-);
+sub build_recipient_list {
+ my ( $self, $row, $h ) = @_;
+
+ return unless @{$self->bodies} == 1;
+ my $body = $self->bodies->[0];
+
+ my $contact = $self->fetch_category($body, $row) or return;
+ my $email = $contact->email;
+ my $area_name = $row->get_extra_field_value('area_name') || '';
+ if ($area_name eq 'Area 7') {
+ my $a7email = FixMyStreet->config('COBRAND_FEATURES') || {};
+ $a7email = $a7email->{open311_email}->{highwaysengland}->{area_seven};
+ $email = $a7email if $a7email;
+ }
+
+ @{$self->to} = map { [ $_, $body->name ] } split /,/, $email;
+ return 1;
+}
1;
diff --git a/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm b/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm
index cf778c549..1ae938317 100644
--- a/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm
+++ b/perllib/FixMyStreet/SendReport/Email/SingleBodyOnly.pm
@@ -15,11 +15,7 @@ sub build_recipient_list {
my $body = $self->bodies->[0];
# We don't care what the category was, look up the relevant contact
- my $contact = $row->result_source->schema->resultset("Contact")->not_deleted->find({
- body_id => $body->id,
- category => $self->contact,
- });
- return unless $contact;
+ my $contact = $self->fetch_category($body, $row, $self->contact) or return;
@{$self->to} = map { [ $_, $body->name ] } split /,/, $contact->email;
return 1;
diff --git a/perllib/FixMyStreet/SendReport/Email/TfL.pm b/perllib/FixMyStreet/SendReport/Email/TfL.pm
deleted file mode 100644
index 383df9792..000000000
--- a/perllib/FixMyStreet/SendReport/Email/TfL.pm
+++ /dev/null
@@ -1,11 +0,0 @@
-package FixMyStreet::SendReport::Email::TfL;
-
-use Moo;
-extends 'FixMyStreet::SendReport::Email::SingleBodyOnly';
-
-has contact => (
- is => 'ro',
- default => 'Traffic lights'
-);
-
-1;
diff --git a/perllib/FixMyStreet/SendReport/Noop.pm b/perllib/FixMyStreet/SendReport/Noop.pm
index 60edda373..933291b7c 100644
--- a/perllib/FixMyStreet/SendReport/Noop.pm
+++ b/perllib/FixMyStreet/SendReport/Noop.pm
@@ -4,9 +4,4 @@ use Moo;
BEGIN { extends 'FixMyStreet::SendReport'; }
-# Always skip when using this method
-sub should_skip {
- return 1;
-}
-
1;
diff --git a/perllib/FixMyStreet/SendReport/Open311.pm b/perllib/FixMyStreet/SendReport/Open311.pm
index a661ff206..e8e840ef5 100644
--- a/perllib/FixMyStreet/SendReport/Open311.pm
+++ b/perllib/FixMyStreet/SendReport/Open311.pm
@@ -29,20 +29,26 @@ sub send {
use_service_as_deviceid => 0,
extended_description => 1,
multi_photos => 0,
+ upload_files => 0,
+ always_upload_photos => 0,
fixmystreet_body => $body,
);
+ my $contact = $self->fetch_category($body, $row) or next;
+
my $cobrand = $body->get_cobrand_handler || $row->get_cobrand_logged;
$cobrand->call_hook(open311_config => $row, $h, \%open311_params);
# Try and fill in some ones that we've been asked for, but not asked the user for
-
- my $contact = $row->result_source->schema->resultset("Contact")->not_deleted->find( {
- body_id => $body->id,
- category => $row->category
- } );
-
my $extra = $row->get_extra_fields();
+ my ($include, $exclude) = $cobrand->call_hook(open311_extra_data => $row, $h, $extra, $contact);
+
+ my $original_extra = [ @$extra ];
+ push @$extra, @$include if $include;
+ if ($exclude) {
+ $exclude = join('|', @$exclude);
+ @$extra = grep { $_->{name} !~ /$exclude/ } @$extra;
+ }
my $id_field = $contact->id_field;
foreach (@{$contact->get_extra_fields}) {
@@ -83,8 +89,8 @@ sub send {
$self->open311_test_req_used($open311->test_req_used);
}
- # make sure we don't save user changes from above
- $row->discard_changes();
+ # make sure we don't save extra changes from above
+ $row->set_extra_fields( @$original_extra );
if ( $resp ) {
$row->external_id( $resp );
@@ -96,7 +102,7 @@ sub send {
$self->error( $self->error . "\n" . $open311->error );
}
- $cobrand->call_hook(open311_post_send => $row, $h);
+ $cobrand->call_hook(open311_post_send => $row, $h, $contact);
}
diff --git a/perllib/FixMyStreet/SendReport/Triage.pm b/perllib/FixMyStreet/SendReport/Triage.pm
new file mode 100644
index 000000000..38341f3ff
--- /dev/null
+++ b/perllib/FixMyStreet/SendReport/Triage.pm
@@ -0,0 +1,20 @@
+package FixMyStreet::SendReport::Triage;
+
+use Moo;
+
+BEGIN { extends 'FixMyStreet::SendReport'; }
+
+sub send {
+ my $self = shift;
+ my ( $row, $h ) = @_;
+
+ $row->update({
+ state => 'for triage'
+ });
+
+ $self->success(1);
+
+ return 0;
+}
+
+1;
diff --git a/perllib/FixMyStreet/SendReport/Zurich.pm b/perllib/FixMyStreet/SendReport/Zurich.pm
index 59adfd688..7416c64f9 100644
--- a/perllib/FixMyStreet/SendReport/Zurich.pm
+++ b/perllib/FixMyStreet/SendReport/Zurich.pm
@@ -29,10 +29,7 @@ sub build_recipient_list {
my $parent = $body->parent;
if ($parent && !$parent->parent) {
# Division, might have an individual contact email address
- my $contact = $row->result_source->schema->resultset("Contact")->find( {
- body_id => $body->id,
- category => $row->category
- } );
+ my $contact = $self->fetch_category($body, $row);
$body_email = $contact->email if $contact && $contact->email;
}
diff --git a/perllib/FixMyStreet/Template.pm b/perllib/FixMyStreet/Template.pm
index 9c565114b..6317f7552 100644
--- a/perllib/FixMyStreet/Template.pm
+++ b/perllib/FixMyStreet/Template.pm
@@ -6,6 +6,10 @@ use warnings;
use FixMyStreet;
use mySociety::Locale;
use Attribute::Handlers;
+use HTML::Scrubber;
+use FixMyStreet::Template::SafeString;
+use FixMyStreet::Template::Context;
+use FixMyStreet::Template::Stash;
my %FILTERS;
my %SUBS;
@@ -37,8 +41,13 @@ sub Fn : ATTR(CODE,BEGIN) {
sub new {
my ($class, $config) = @_;
+ my $disable_autoescape = delete $config->{disable_autoescape};
$config->{FILTERS}->{$_} = $FILTERS{$_} foreach keys %FILTERS;
$config->{ENCODING} = 'utf8';
+ if (!$disable_autoescape) {
+ $config->{STASH} = FixMyStreet::Template::Stash->new($config);
+ $config->{CONTEXT} = FixMyStreet::Template::Context->new($config);
+ }
$class->SUPER::new($config);
}
@@ -53,11 +62,14 @@ sub process {
[% loc('Some text to localize', 'Optional comment for translator') %]
Passes the text to the localisation engine for translations.
+Pass in "JS" as the optional comment to escape single quotes (for use in JavaScript).
=cut
sub loc : Fn {
- return _(@_);
+ my $s = _(@_);
+ $s =~ s/'/\\'/g if $_[1] && $_[1] eq 'JS';
+ return FixMyStreet::Template::SafeString->new($s);
}
=head2 nget
@@ -69,7 +81,7 @@ Use first or second string depending on the number.
=cut
sub nget : Fn {
- return mySociety::Locale::nget(@_);
+ return FixMyStreet::Template::SafeString->new(mySociety::Locale::nget(@_));
}
=head2 file_exists
@@ -104,6 +116,12 @@ sub html_filter : Filter('html') {
return $text;
}
+sub conditional_escape {
+ my $text = shift;
+ $text = html_filter($text) unless UNIVERSAL::isa($text, 'FixMyStreet::Template::SafeString');
+ return $text;
+}
+
=head2 html_paragraph
Same as Template Toolkit's html_paragraph, but converts single newlines
@@ -113,9 +131,27 @@ into <br>s too.
sub html_paragraph : Filter('html_para') {
my $text = shift;
- my @paras = split(/(?:\r?\n){2,}/, $text);
+ $text = conditional_escape($text);
+ my @paras = grep { $_ } split(/(?:\r?\n){2,}/, $text);
s/\r?\n/<br>\n/g for @paras;
$text = "<p>\n" . join("\n</p>\n\n<p>\n", @paras) . "</p>\n";
+ return FixMyStreet::Template::SafeString->new($text);
+}
+
+sub sanitize {
+ my $text = shift;
+
+ my %allowed_tags = map { $_ => 1 } qw( p ul ol li br b i strong em );
+ my $scrubber = HTML::Scrubber->new(
+ rules => [
+ %allowed_tags,
+ a => { href => qr{^(http|/|tel)}i, style => 1, target => qr/^_blank$/, title => 1, class => qr/^js-/ },
+ img => { src => 1, alt => 1, width => 1, height => 1, hspace => 1, vspace => 1, align => 1, sizes => 1, srcset => 1 },
+ font => { color => 1 },
+ span => { style => 1 },
+ ]
+ );
+ $text = $scrubber->scrub($text);
return $text;
}
diff --git a/perllib/FixMyStreet/Template/Context.pm b/perllib/FixMyStreet/Template/Context.pm
new file mode 100644
index 000000000..de3212095
--- /dev/null
+++ b/perllib/FixMyStreet/Template/Context.pm
@@ -0,0 +1,67 @@
+package FixMyStreet::Template::Context;
+
+use strict;
+use warnings;
+use base qw(Template::Context);
+
+sub filter {
+ my $self = shift;
+ my ($name, $args, $alias) = @_;
+
+ # If we're passing through the safe filter, then unwrap
+ # from a Template::HTML::Variable if we are one.
+ if ( $name eq 'safe' ) {
+ return sub {
+ my $value = shift;
+ return $value->plain if UNIVERSAL::isa($value, 'FixMyStreet::Template::Variable');
+ return $value;
+ };
+ }
+
+ my $filter = $self->SUPER::filter(@_);
+
+ # If we are already going to auto-encode, we don't want to do it again.
+ # This makes the html filter a no-op on auto-encoded variables.
+ if ( $name eq 'html' ) {
+ return sub {
+ my $value = shift;
+ return $value if UNIVERSAL::isa($value, 'FixMyStreet::Template::Variable');
+ return $filter->($value);
+ };
+ }
+
+ return sub {
+ my $value = shift;
+
+ if ( UNIVERSAL::isa($value, 'FixMyStreet::Template::Variable') ) {
+ my $result = $filter->($value->plain);
+ return $result if UNIVERSAL::isa($result, 'FixMyStreet::Template::SafeString');
+ return ref($value)->new($result);
+ }
+
+ return $filter->($value);
+ };
+}
+
+1;
+__END__
+
+=head1 NAME
+
+FixMyStreet::Template::Context - Similar to Template::HTML::Context but use
+'safe' rather than 'none' to be clear, also prevents html filter double-encoding,
+and doesn't rewrap a FixMyStreet::Template::SafeString.
+
+=head1 AUTHORS
+
+Martyn Smith, E<lt>msmith@cpan.orgE<gt>
+
+Matthew Somerville, E<lt>matthew@mysociety.orgE<gt>
+
+=head1 LICENSE
+
+This library is free software; you can redistribute it and/or modify
+it under the same terms as Perl itself, either Perl version 5.8.8 or,
+at your option, any later version of Perl 5 you may have available.
+
+=cut
diff --git a/perllib/FixMyStreet/Template/SafeString.pm b/perllib/FixMyStreet/Template/SafeString.pm
new file mode 100644
index 000000000..263937b39
--- /dev/null
+++ b/perllib/FixMyStreet/Template/SafeString.pm
@@ -0,0 +1,112 @@
+package FixMyStreet::Template::SafeString;
+
+use strict;
+use warnings;
+
+=head1 NAME
+
+FixMyStreet::Template::SafeString - a string that won't be escaped on output in a template
+
+=cut
+
+use overload
+ '""' => sub { ${$_[0]} },
+ '.' => \&concat,
+ '.=' => \&concatequals,
+ '=' => \&clone,
+ 'cmp' => \&cmp,
+;
+
+sub new {
+ my ($class, $value) = @_;
+
+ my $self = bless \$value, $class;
+
+ return $self;
+}
+
+sub cmp {
+ my ($self, $str) = @_;
+
+ if (ref $str eq __PACKAGE__) {
+ return $$self cmp $$str;
+ } else {
+ return $$self cmp $str;
+ }
+}
+
+sub concat {
+ my ($self, $str, $prefix) = @_;
+
+ return $self->clone() if not defined $str or $str eq '';
+
+ if ( $prefix ) {
+ return $str . $$self;
+ } else {
+ return $$self . $str;
+ }
+}
+
+sub concatequals {
+ my ($self, $str, $prefix) = @_;
+
+ if ( ref $str eq __PACKAGE__) {
+ $$self .= $$str;
+ return $self;
+ } else {
+ return $self->clone() if $str eq '';
+ $$self .= $str;
+ return $$self;
+ }
+}
+
+sub clone {
+ my $self = shift;
+
+ my $val = $$self;
+ my $clone = bless \$val, ref $self;
+
+ return $clone;
+}
+
+sub TO_JSON {
+ my $self = shift;
+
+ return $$self;
+}
+
+1;
+__END__
+
+=head1 SYNOPSIS
+
+ use FixMyStreet::Template;
+ use FixMyStreet::Template::SafeString;
+
+ my $s1 = "< test & stuff >";
+ my $s2 = FixMyStreet::Template::SafeString->new($s1);
+
+ my $tt = FixMyStreet::Template->new();
+ $tt->process(\"[% s1 %] * [% s2 %]\n", { s1 => $s1, s2 => $s2 });
+
+ # Produces output "&lt; test &amp; stuff &gt; * < test & stuff >"
+
+=head1 DESCRIPTION
+
+This object provides a safe string to use as part of the FixMyStreet::Template
+extension. It will not be automatically escaped when used, so can be used to
+pass HTML to a template by a function that is safely creating some.
+
+=head1 AUTHOR
+
+Matthew Somerville, E<lt>matthew@mysociety.orgE<gt>
+
+Martyn Smith, E<lt>msmith@cpan.orgE<gt>
+
+=head1 LICENSE
+
+This library is free software; you can redistribute it and/or modify
+it under the same terms as Perl itself, either Perl version 5.8.8 or,
+at your option, any later version of Perl 5 you may have available.
+
+=cut
diff --git a/perllib/FixMyStreet/Template/Stash.pm b/perllib/FixMyStreet/Template/Stash.pm
new file mode 100644
index 000000000..dd027400e
--- /dev/null
+++ b/perllib/FixMyStreet/Template/Stash.pm
@@ -0,0 +1,75 @@
+package FixMyStreet::Template::Stash;
+
+use strict;
+use warnings;
+use base qw(Template::Stash);
+use FixMyStreet::Template::Variable;
+use Scalar::Util qw(blessed);
+
+sub get {
+ my $self = shift;
+
+ my $value = $self->SUPER::get(@_);
+
+ $value = FixMyStreet::Template::Variable->new($value) unless ref $value;
+
+ return $value;
+}
+
+# To deal with being able to call var.upper or var.match
+sub _dotop {
+ my $self = shift;
+ my ($root, $item, $args, $lvalue) = @_;
+
+ $args ||= [ ];
+ $lvalue ||= 0;
+
+ return undef unless defined($root) and defined($item);
+ return undef if $item =~ /^[_.]/;
+
+ if (blessed($root) && $root->isa('FixMyStreet::Template::Variable')) {
+ if ((my $value = $Template::Stash::SCALAR_OPS->{ $item }) && ! $lvalue) {
+ my @result = &$value($root->{value}, @$args);
+ if (defined $result[0]) {
+ return scalar @result > 1 ? [ @result ] : $result[0];
+ }
+ return undef;
+ }
+ }
+
+ return $self->SUPER::_dotop(@_);
+}
+
+1;
+__END__
+
+=head1 NAME
+
+FixMyStreet::Template::Stash - The same as Template::HTML::Stash, but
+additionally copes with scalar operations on stash items.
+
+=head1 FUNCTIONS
+
+=head2 get()
+
+An overridden function from Template::Stash that calls the parent class's get
+method, and returns a FixMyStreet::Template::Variable instead of a raw string.
+
+=head2 _dotop()
+
+An overridden function from Template::Stash so that scalar operations on
+wrapped FixMyStreet::Template::Variable strings still function correctly.
+
+=head1 AUTHOR
+
+Martyn Smith, E<lt>msmith@cpan.orgE<gt>
+
+Matthew Somerville, E<lt>matthew@mysociety.orgE<gt>
+
+=head1 LICENSE
+
+This library is free software; you can redistribute it and/or modify
+it under the same terms as Perl itself, either Perl version 5.8.8 or,
+at your option, any later version of Perl 5 you may have available.
+
+=cut
diff --git a/perllib/FixMyStreet/Template/Variable.pm b/perllib/FixMyStreet/Template/Variable.pm
new file mode 100644
index 000000000..0142a4db5
--- /dev/null
+++ b/perllib/FixMyStreet/Template/Variable.pm
@@ -0,0 +1,179 @@
+package FixMyStreet::Template::Variable;
+
+use strict;
+use warnings;
+use FixMyStreet::Template;
+
+sub op_factory {
+ my ($op) = @_;
+
+ return eval q|sub {
+ my ($self, $str, $swapped) = @_;
+
+ if ( ref $str eq __PACKAGE__) {
+ return $self->{value} | . $op . q| $str->{value} unless $swapped;
+ return $str->{value} | . $op . q| $self->{value};
+ }
+ else {
+ return $self->{value} | . $op . q| $str unless $swapped;
+ return $str | . $op . q| $self->{value};
+ }
+ }|;
+}
+
+use overload
+ '""' => \&html_encoded,
+ '.' => \&concat,
+ '.=' => \&concatequals,
+ '=' => \&clone,
+
+ 'cmp' => op_factory('cmp'),
+ 'eq' => op_factory('eq'),
+ '<=>' => op_factory('<=>'),
+ '==' => op_factory('=='),
+ '%' => op_factory('%'),
+ '+' => op_factory('+'),
+ '-' => op_factory('-'),
+ '*' => op_factory('*'),
+ '/' => op_factory('/'),
+ '**' => op_factory('**'),
+ '>>' => op_factory('>>'),
+ '<<' => op_factory('<<'),
+;
+
+sub new {
+ my ($class, $value) = @_;
+
+ my $self = bless { value => $value }, $class;
+
+ return $self;
+}
+
+sub plain {
+ my $self = shift;
+
+ return $self->{value};
+}
+
+sub html_encoded {
+ my $self = shift;
+ return FixMyStreet::Template::html_filter($self->{value});
+}
+
+sub concat {
+ my ($self, $str, $prefix) = @_;
+
+ # Special case where we're _not_ going to html_encode now now
+ return $self->clone() if not defined $str or $str eq '';
+
+ if ( $prefix ) {
+ return $str . $self->html_encoded();
+ }
+ else {
+ return $self->html_encoded() . $str;
+ }
+}
+
+sub concatequals {
+ my ($self, $str, $prefix) = @_;
+
+ if ( ref $str eq __PACKAGE__) {
+ $self->{value} .= $str->{value};
+ return $self;
+ }
+ else {
+ # Special case where we're _not_ going to html_encode now now
+ return $self->clone() if $str eq '';
+
+ # Fix Template::HTML::Variable issue with double output
+ my $ret = $self->html_encoded . $str;
+ $self->{value} .= $str;
+ return $ret;
+ }
+}
+
+sub clone {
+ my $self = shift;
+
+ my $clone = bless { %$self }, ref $self;
+
+ return $clone;
+}
+
+1;
+__END__
+
+=head1 NAME
+
+FixMyStreet::Template::Variable - A "pretend" string that auto HTML encodes;
+a copy of Template::HTML::Variable with a bugfix.
+
+=head1 SYNOPSIS
+
+ use FixMyStreet::Template::Variable;
+
+ my $string = FixMyStreet::Template::Variable->new('< test & stuff >');
+
+ print $string, "\n";
+
+ # Produces output "&lt; test &amp; stuff &gt;"
+
+=head1 DESCRIPTION
+
+This object provides a "pretend" string to use as part of the
+FixMyStreet::Template extension.
+
+It automatically stringifies to an HTML encoded version of what it was created
+with, all the while trying to keep a sane state through string concatinations
+etc.
+
+=head1 FUNCTIONS
+
+=head2 new()
+
+Takes a single argument which is the string to set this variable to
+
+=head2 plain()
+
+Returns a non HTML-encoded version of the string (i.e. exactly what was passed
+to the new() function
+
+=head2 html_encoded()
+
+Returns an HTML encoded version of the string (used by the stringify
+overloads)
+
+=head2 concat()
+
+Implementation of overloaded . operator
+
+=head2 concatequals()
+
+Implementation of overloaded .= operator.
+
+The original Template::HTML::Variable has a bug here, whereby it adds the new
+string to its internal value, then returns the HTML encoded version of the
+whole string with the new string concatenated again (unescaped).
+
+=head2 clone()
+
+Returns a clone of this variable. (used for the implementation of the
+overloaded = operator).
+
+=head2 op_factory()
+
+Factory for generating operator overloading subs
+
+=head1 AUTHOR
+
+Martyn Smith, E<lt>msmith@cpan.orgE<gt>
+
+Matthew Somerville, E<lt>matthew@mysociety.orgE<gt>
+
+=head1 LICENSE
+
+This library is free software; you can redistribute it and/or modify
+it under the same terms as Perl itself, either Perl version 5.8.8 or,
+at your option, any later version of Perl 5 you may have available.
+
+=cut
diff --git a/perllib/FixMyStreet/TestAppProve.pm b/perllib/FixMyStreet/TestAppProve.pm
index 0329bcfde..ec245e72c 100644
--- a/perllib/FixMyStreet/TestAppProve.pm
+++ b/perllib/FixMyStreet/TestAppProve.pm
@@ -104,6 +104,8 @@ sub run {
my $config_out = $class->get_config({ config_file => $config_file, db_config_file => $db_config_file });
local $ENV{FMS_OVERRIDE_CONFIG} = $config_out;
+ # Don't warn over use of Regex dispatch type
+ local $ENV{CATALYST_NOWARN_DEPRECATE} = 1;
my $prove = App::Prove->new;
$prove->process_args(@ARGV);
@@ -111,7 +113,7 @@ sub run {
# If no arguments, test everything
$prove->argv(['t']) unless @{$prove->argv};
# verbose if we have a single file
- $prove->verbose(1) if @{$prove->argv} and -f $prove->argv->[-1];
+ $prove->verbose(1) if @{$prove->argv} and -f $prove->argv->[-1] && !$ENV{CI};
# we always want to recurse
$prove->recurse(1);
# we always want to save state
diff --git a/perllib/FixMyStreet/TestMech.pm b/perllib/FixMyStreet/TestMech.pm
index 3ecb13b6a..1b7fba1bd 100644
--- a/perllib/FixMyStreet/TestMech.pm
+++ b/perllib/FixMyStreet/TestMech.pm
@@ -56,6 +56,26 @@ sub logged_in_ok {
"logged in" );
}
+=head2 uniquify_email
+
+Given an email address, will add the caller to it so that it can be unique per
+file. You can pass a caller file in yourself if e.g. you're another function in
+this file.
+
+=cut
+
+sub uniquify_email {
+ my ($self, $email, $file) = @_;
+
+ $file = (caller)[1] unless $file;
+ (my $pkg = $file) =~ s{[/\.]}{}g;
+
+ if ($email =~ /@/ && $email !~ /^pkg-/) {
+ $email = "pkg-$pkg-$email";
+ }
+ return $email;
+}
+
=head2 create_user_ok
$user = $mech->create_user_ok( $email );
@@ -68,8 +88,9 @@ sub create_user_ok {
my $self = shift;
my ( $username, %extra ) = @_;
+ $username = $self->uniquify_email($username, (caller)[1]);
my $params = { %extra };
- $username =~ /@/ ? $params->{email} = $username : $params->{phone} = $username;
+ $username =~ /@/ ? ($params->{email} = $username) : ($params->{phone} = $username);
my $user = FixMyStreet::DB->resultset('User')->find_or_create($params);
ok $user, "found/created user for $username";
@@ -88,6 +109,9 @@ sub log_in_ok {
my $mech = shift;
my $username = shift;
+ $mech->get_ok('/auth'); # Doing this here so schema cobrand set appropriately (for e.g. TfL password setting)
+
+ $username = $mech->uniquify_email($username, (caller)[1]);
my $user = $mech->create_user_ok($username);
# remember the old password and then change it to a known one
@@ -95,7 +119,6 @@ sub log_in_ok {
$user->update( { password => 'secret' } );
# log in
- $mech->get_ok('/auth');
$mech->submit_form_ok(
{ with_fields => { username => $username, password_sign_in => 'secret' } },
"sign in using form" );
@@ -103,12 +126,7 @@ sub log_in_ok {
# restore the password (if there was one)
if ($old_password) {
-
- # Use store_column and then make_column_dirty to bypass the filters that
- # would hash the password, otherwise the password required ito log in
- # would be the hash of the previous one.
- $user->store_column("password", $old_password);
- $user->make_column_dirty("password");
+ $user->password($old_password, 1);
$user->update();
# Belt and braces, check that the password has been correctly saved.
@@ -229,6 +247,17 @@ sub get_email {
return $emails[0];
}
+sub get_email_envelope {
+ my $mech = shift;
+ my @emails = FixMyStreet::Email::Sender->default_transport->deliveries;
+ @emails = map { $_->{envelope} } @emails;
+
+ return @emails if wantarray;
+
+ $mech->email_count_is(1) || return undef;
+ return $emails[0];
+}
+
sub get_text_body_from_email {
my ($mech, $email, $obj) = @_;
unless ($email) {
@@ -241,7 +270,7 @@ sub get_text_body_from_email {
my $part = shift;
return if $part->subparts;
return if $part->content_type !~ m{text/plain};
- $body = $obj ? $part : $part->body;
+ $body = $obj ? $part : $part->body_str;
ok $body, "Found text body";
});
return $body;
@@ -556,7 +585,7 @@ sub get_ok_json {
# check that the content-type of response is correct
croak "Response was not JSON"
- unless $res->header('Content-Type') =~ m{^application/json\b};
+ unless $res->header('Content-Type') =~ m{^application/(?:[a-z]+\+)?json\b};
return decode_json( $res->content );
}
@@ -652,8 +681,9 @@ sub create_problems_for_body {
my $dt = $params->{dt} || DateTime->now();
+ my $email = $mech->uniquify_email('test@example.com', (caller)[1]);
my $user = $params->{user} ||
- FixMyStreet::DB->resultset('User')->find_or_create( { email => 'test@example.com', name => 'Test User' } );
+ FixMyStreet::DB->resultset('User')->find_or_create( { email => $email, name => 'Test User' } );
delete $params->{user};
delete $params->{dt};
@@ -707,6 +737,7 @@ sub create_comment_for_problem {
$params->{problem_state} = $problem_state;
$params->{state} = $state;
$params->{mark_fixed} = $problem_state && FixMyStreet::DB::Result::Problem->fixed_states()->{$problem_state} ? 1 : 0;
+ $params->{confirmed} = \'current_timestamp' unless $params->{confirmed} || $state eq 'unconfirmed';
FixMyStreet::App->model('DB::Comment')->create($params);
}
@@ -718,7 +749,7 @@ sub encoded_content {
sub content_as_csv {
my $self = shift;
- open my $data_handle, '<', \$self->content;
+ open my $data_handle, '<:encoding(utf-8)', \$self->encoded_content;
my $csv = Text::CSV->new({ binary => 1 });
my @rows;
while (my $row = $csv->getline($data_handle)) {