diff options
91 files changed, 3496 insertions, 356 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afd39446..2bf209262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ made in a private category. #2528 - Improve map JavaScript defensiveness. - Upgrade jquery-validation plugin. #2540 + - Pass ‘filter_category’ param to front page to pre-filter map. - Admin improvements: - Add new roles system, to group permissions and apply to users. #2483 - New features: - Categories can be listed under more than one group #2475 + - OpenID Connect login support. #2523 - Bugfixes: - Prevent creation of two templates with same title. #2471 - Fix bug going between report/new pages client side. #2484 @@ -46,6 +48,7 @@ - Support receiving updates from external source. - Improve JSON output of controller. - unset external_status_code if blank in update + - Add support for account_id parameter to POST Service Request calls. * v2.6 (3rd May 2019) - New features: diff --git a/bin/update-schema b/bin/update-schema index 900e628e6..3f4b2bafe 100755 --- a/bin/update-schema +++ b/bin/update-schema @@ -212,6 +212,7 @@ else { # (assuming schema change files are never half-applied, which should be the case) sub get_db_version { return 'EMPTY' if ! table_exists('problem'); + return '0068' if column_exists('users', 'oidc_ids'); return '0067' if table_exists('roles'); return '0066' if column_exists('users', 'area_ids'); return '0065' if constraint_contains('admin_log_object_type_check', 'moderation'); diff --git a/bin/westminster/fixture b/bin/westminster/fixture new file mode 100755 index 000000000..4f7a86afb --- /dev/null +++ b/bin/westminster/fixture @@ -0,0 +1,356 @@ +#!/usr/bin/env perl + +use warnings; +use v5.14; +use utf8; + +BEGIN { + use File::Basename qw(dirname); + use File::Spec; + my $d = dirname(File::Spec->rel2abs($0)); + require "$d/../../setenv.pl"; +} + +use FixMyStreet; +use FixMyStreet::DB::Factories; +use Getopt::Long::Descriptive; + +my ($opt, $usage) = describe_options( + '%c %o', + [ 'empty', "Empty all tables of the database first" ], + [ 'commit', "Actually commit changes to the database" ], + [ 'help', "print usage message and exit", { shortcircuit => 1 } ], +); +print($usage->text), exit if $opt->help; + +FixMyStreet::DB::Factories->setup($opt); + +# Westminster City Council https://mapit.mysociety.org/area/2504.html +my $area_id = 2504; + +my $group_drainage = [ + 'Drainage or Surface water', +]; +my $group_flyposting = [ + 'Flyposting', + 'Stickers', + 'Graffiti', +]; +my $group_noise = [ + 'Aircraft', + 'Basement construction', + 'Birds', + 'Building site', + 'Burglar or fire alarms', + 'Buskers', + 'Car alarm', + 'Crossrail or Tideway construction', + 'Dogs barking or animal noise', + 'Noise from a business', + 'Noise from a home', + 'Noise in the street', + 'Underground tube trains or stations', +]; + +# Despite appearances, this will create a new Body every time, +# because it uses the `key_field` of `id` (see Factories.pm) to +# find existing bodies, and we’re not providing an `id` here. +# I guess it makes sense, because you might want a single MapIt +# area to be covered by multiple administrative bodies. +my $body = FixMyStreet::DB::Factory::Body->find_or_create({ + area_id => $area_id, + categories => [ @$group_drainage, @$group_flyposting, @$group_noise ], +}); + +say "Found/created body " . $body->name . " for MapIt area ID " . $area_id; + +my $yes_no_list = [ + { 'name' => 'Yes', 'key' => 'yes' }, + { 'name' => 'No', 'key' => 'no' }, +]; + +for my $cat (@$group_drainage) { + my $child_cat = FixMyStreet::DB->resultset("Contact")->find({ + body => $body, + category => $cat + }); + $child_cat->set_extra_fields( + { + description => 'Note: Please report dangerous issues by telephone on 0207 641 2000.', + hint => 'For example where there is a danger of causing serious harm to the public or significant damage to property, including a missing drain cover or immediate flooding to a property.', + code => 'danger', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + { + description => 'What is the problem with the drain?', + code => 'drain_problem_type', + required => 'false', + variable => 'true', + datatype => 'singlevaluelist', + values => [ + { 'name' => 'Burst water main', 'key' => 'burst' }, + { 'name' => 'Blocked drain', 'key' => 'blocked' }, + { 'name' => 'Flooding from a blocked drain', 'key' => 'flooded' }, + { 'name' => 'Smelly drain', 'key' => 'smelly' }, + { 'name' => 'Broken drain cover', 'key' => 'broken' }, + { 'name' => 'Missing drain cover', 'key' => 'missing' }, + { 'name' => 'Rats, flies or cockroaches', 'key' => 'vermin' }, + ], + }, + { + description => 'What type of customer are you?', + code => 'customer_type', + required => 'false', + variable => 'true', + datatype => 'singlevaluelist', + values => [ + { 'name' => 'Member of the public', 'key' => 'public' }, + { 'name' => 'Company', 'key' => 'company' }, + { 'name' => 'Police', 'key' => 'police' }, + { 'name' => 'Emergency services', 'key' => 'emergency' }, + { 'name' => 'Council officer', 'key' => 'officer' }, + { 'name' => 'Council contractor', 'key' => 'contractor' }, + ], + }, + ); + $child_cat->update; +} + +say "Created drainage category"; + +for my $cat (@$group_flyposting) { + my $child_cat = FixMyStreet::DB->resultset("Contact")->find({ + body => $body, + category => $cat + }); + $child_cat->set_extra_metadata( group => 'Graffiti or Flyposting' ); + $child_cat->set_extra_fields( + { + description => 'Surface type', + code => 'surface_type', + required => 'false', + variable => 'true', # set 'false' to hide data input + datatype => 'singlevaluelist', + values => [ + { 'name' => 'Brick', 'key' => 'brick' }, + { 'name' => 'Concrete', 'key' => 'concrete' }, + { 'name' => 'Glass', 'key' => 'glass' }, + { 'name' => 'Metal', 'key' => 'metal' }, + { 'name' => 'Plastic', 'key' => 'plastic' }, + { 'name' => 'Stone', 'key' => 'stone' }, + { 'name' => 'Tarmac', 'key' => 'tarmac' }, + { 'name' => 'Other / not known', 'key' => 'other' }, + ], + }, + { + description => 'Size', + code => 'size', + required => 'false', + variable => 'true', + datatype => 'singlevaluelist', + values => [ + { 'name' => 'less than 1m sq', 'key' => 'small' }, + { 'name' => '1m sq to 3m sq', 'key' => 'medium' }, + { 'name' => '3m sq +', 'key' => 'large' }, + ], + }, + { + description => 'Is it offensive?', + code => 'offensive', + required => 'false', + variable => 'true', + datatype => 'singlevaluelist', + values => $yes_no_list, + }, + ); + $child_cat->update; +} + +say "Created flyposting categories"; + +my $happening_now = { + description => 'Is the noise happening now?', + code => 'now', + required => 'false', + variable => 'true', + datatype => 'singlevaluelist', + values => $yes_no_list, +}; + +for my $cat (@$group_noise) { + my $child_cat = FixMyStreet::DB->resultset("Contact")->find({ + body => $body, + category => $cat + }); + $child_cat->set_extra_metadata( group => 'Noise' ); + + if ( $cat eq 'Aircraft' ) { + $child_cat->set_extra_fields( + { + description => 'Please contact the Civil Aviation Authority, or Ministry of Defence if it’s a military aircraft.', + code => 'go_away', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + ); + + } elsif ( $cat eq 'Basement construction') { + $child_cat->set_extra_fields( + { + description => 'Note: Building work is only allowed from 8am to 6pm on weekdays, and 8am to 1pm on Saturdays. Building work that is extremely noisy, such as demolition, is not allowed in residential areas at weekends. Builders need special permission from the council to work outside these times.', + code => 'building_permits', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + { + description => 'What is the name of the person responsible?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Birds') { + $child_cat->set_extra_fields( + $happening_now, + ); + + } elsif ( $cat eq 'Building site') { + $child_cat->set_extra_fields( + { + description => 'What is the name of the business making the noise?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Burglar or fire alarms') { + $child_cat->set_extra_fields( + { + description => 'What is the name of the person or business making the noise?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Busking') { + $child_cat->set_extra_fields( + { + description => 'Note: Busking is not licensed in Westminster, nor is it illegal. If a busker is behaving unreasonably the Council can help.', + code => 'danger', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Car alarm') { + $child_cat->set_extra_fields( + { + description => 'Vehicle registration', + code => 'vehicle_registration', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Crossrail or Tideway construction') { + $child_cat->set_extra_fields( + { + description => 'Please contact Crossrail or Tideway directly.', + code => 'go_away', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + ); + + } elsif ( $cat eq 'Dogs barking or animal noise') { + $child_cat->set_extra_fields( + { + description => 'Note: Animal cruelty should be reported to the RSPCA.', + code => 'rspca', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + { + description => 'What is the name of the person or business making the noise?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Noise from a business') { + $child_cat->set_extra_fields( + { + description => 'What is the name of the business making the noise?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Noise from a home') { + $child_cat->set_extra_fields( + { + description => 'What is the name of the person responsible?', + code => 'responsible', + required => 'false', + variable => 'true', + datatype => 'string', + }, + $happening_now, + ); + + } elsif ( $cat eq 'Noise in the street') { + $child_cat->set_extra_fields( + $happening_now, + ); + + } elsif ( $cat eq 'Underground tube trains or stations') { + $child_cat->set_extra_fields( + { + description => 'TFL are a Statutory Undertaker and you should contact them first to investigate your complaint. If they have failed to resolve your complaint please continue with this form, including your TFL reference number, and we will look into this for you further.', + code => 'tfl', + required => 'false', + variable => 'false', # set 'true' to show data input + datatype => 'string', + }, + $happening_now, + ); + + } + + $child_cat->update; +} + +say "Created noise categories"; + +foreach ( + { name => 'Wizard of Oz', email_verified => 1, email => 'admin@example.org', is_superuser => 't' }, + { name => 'Norma User', email_verified => 1, email => 'user@example.org' }, +) { + FixMyStreet::DB::Factory::User->find_or_create($_); + say "Found/created user $_->{email}"; +} @@ -107,6 +107,7 @@ requires 'Net::Facebook::Oauth2', '0.11'; requires 'Net::OAuth'; requires 'Net::Twitter::Lite::WithAPIv1_1', '0.12008'; requires 'Number::Phone', '3.5000'; +requires 'OIDC::Lite'; requires 'Path::Class'; requires 'POSIX'; requires 'Readonly'; diff --git a/cpanfile.snapshot b/cpanfile.snapshot index 62f454435..273e4827c 100644 --- a/cpanfile.snapshot +++ b/cpanfile.snapshot @@ -292,6 +292,12 @@ DISTRIBUTIONS Storable 0 String::CRC32 0 Time::HiRes 0 + Canary-Stability-2013 + pathname: M/ML/MLEHMANN/Canary-Stability-2013.tar.gz + provides: + Canary::Stability 2013 + requirements: + ExtUtils::MakeMaker 0 Capture-Tiny-0.40 pathname: D/DA/DAGOLDEN/Capture-Tiny-0.40.tar.gz provides: @@ -846,6 +852,13 @@ DISTRIBUTIONS Class::Data::Inheritable 0.08 requirements: ExtUtils::MakeMaker 0 + Class-ErrorHandler-0.04 + pathname: T/TO/TOKUHIROM/Class-ErrorHandler-0.04.tar.gz + provides: + Class::ErrorHandler 0.04 + requirements: + ExtUtils::MakeMaker 0 + perl 5.008_001 Class-Factory-Util-1.7 pathname: D/DR/DROLSKY/Class-Factory-Util-1.7.tar.gz provides: @@ -1064,12 +1077,70 @@ DISTRIBUTIONS perl 5.006 strict 0 warnings 0 + Crypt-OpenSSL-Guess-0.11 + pathname: A/AK/AKIYM/Crypt-OpenSSL-Guess-0.11.tar.gz + provides: + Crypt::OpenSSL::Guess 0.11 + requirements: + Config 0 + Exporter 5.57 + ExtUtils::MakeMaker 6.64 + File::Spec 0 + Symbol 0 + perl 5.008001 + Crypt-OpenSSL-RSA-0.31 + pathname: T/TO/TODDR/Crypt-OpenSSL-RSA-0.31.tar.gz + provides: + Crypt::OpenSSL::RSA 0.31 + requirements: + Crypt::OpenSSL::Random 0 + ExtUtils::MakeMaker 0 + Test::More 0 + perl 5.006 + Crypt-OpenSSL-Random-0.15 + pathname: R/RU/RURBAN/Crypt-OpenSSL-Random-0.15.tar.gz + provides: + Crypt::OpenSSL::Random 0.15 + requirements: + Crypt::OpenSSL::Guess 0.11 + ExtUtils::MakeMaker 0 Crypt-RC4-2.02 pathname: S/SI/SIFUKURT/Crypt-RC4-2.02.tar.gz provides: Crypt::RC4 2.02 requirements: ExtUtils::MakeMaker 0 + Crypt-Random-Source-0.14 + pathname: E/ET/ETHER/Crypt-Random-Source-0.14.tar.gz + provides: + Crypt::Random::Source 0.14 + Crypt::Random::Source::Base 0.14 + Crypt::Random::Source::Base::File 0.14 + Crypt::Random::Source::Base::Handle 0.14 + Crypt::Random::Source::Base::Proc 0.14 + Crypt::Random::Source::Base::RandomDevice 0.14 + Crypt::Random::Source::Factory 0.14 + Crypt::Random::Source::Strong 0.14 + Crypt::Random::Source::Strong::devrandom 0.14 + Crypt::Random::Source::Weak 0.14 + Crypt::Random::Source::Weak::devurandom 0.14 + requirements: + Capture::Tiny 0.08 + Carp 0 + ExtUtils::MakeMaker 0 + File::Spec 0 + IO::File 1.14 + IO::Handle 0 + Module::Build::Tiny 0.034 + Module::Find 0 + Module::Runtime 0 + Moo 1.002000 + Sub::Exporter 0 + Types::Standard 0 + namespace::clean 0.11 + perl 5.008 + strict 0 + warnings 0 CryptX-0.059 pathname: M/MI/MIK/CryptX-0.059.tar.gz provides: @@ -1463,7 +1534,7 @@ DISTRIBUTIONS DBIx::Class::IntrospectableM2M 0.001001 requirements: DBIx::Class 0 - ExtUtils::MakeMaker 6.72 + ExtUtils::MakeMaker 7.34 Test::More 0 DBIx-Class-QueryLog-1.005001 pathname: F/FR/FREW/DBIx-Class-QueryLog-1.005001.tar.gz @@ -2468,6 +2539,7 @@ DISTRIBUTIONS Dir::Self 0.11 requirements: Carp 0 + ExtUtils::MakeMaker 6.48 File::Spec 0 strict 0 Dist-CheckConflicts-0.09 @@ -3674,6 +3746,34 @@ DISTRIBUTIONS ExtUtils::MakeMaker 0 Scalar::Util 1.08 Test::More 0 + JSON-WebToken-0.10 + pathname: X/XA/XAICRON/JSON-WebToken-0.10.tar.gz + provides: + JSON::WebToken 0.10 + JSON::WebToken::Constants undef + JSON::WebToken::Crypt undef + JSON::WebToken::Crypt::HMAC undef + JSON::WebToken::Crypt::RSA undef + JSON::WebToken::Exception undef + requirements: + Carp 0 + Digest::SHA 0 + Exporter 0 + JSON 0 + MIME::Base64 0 + Module::Build 0.38 + Module::Runtime 0 + parent 0 + perl 5.008001 + JSON-XS-4.02 + pathname: M/ML/MLEHMANN/JSON-XS-4.02.tar.gz + provides: + JSON::XS 4.02 + requirements: + Canary::Stability 0 + ExtUtils::MakeMaker 6.52 + Types::Serialiser 0 + common::sense 0 LWP-MediaTypes-6.02 pathname: G/GA/GAAS/LWP-MediaTypes-6.02.tar.gz provides: @@ -3978,6 +4078,15 @@ DISTRIBUTIONS Net::Domain 1.05 Net::SMTP 1.03 Test::More 0 + Math-Random-ISAAC-1.004 + pathname: J/JA/JAWNSY/Math-Random-ISAAC-1.004.tar.gz + provides: + Math::Random::ISAAC 1.004 + Math::Random::ISAAC::PP 1.004 + requirements: + ExtUtils::MakeMaker 6.31 + Test::More 0.62 + Test::NoWarnings 0.084 Math-Random-MT-1.17 pathname: F/FA/FANGLY/Math-Random-MT-1.17.tar.gz provides: @@ -3986,6 +4095,16 @@ DISTRIBUTIONS ExtUtils::MakeMaker 0 Test::More 0 Test::Number::Delta 0 + Math-Random-Secure-0.080001 + pathname: F/FR/FREW/Math-Random-Secure-0.080001.tar.gz + provides: + Math::Random::Secure 0.080001 + Math::Random::Secure::RNG 0.080001 + requirements: + Crypt::Random::Source 0.07 + ExtUtils::MakeMaker 0 + Math::Random::ISAAC 1.001 + Moo 2 Memoize-ExpireLRU-0.55 pathname: B/BP/BPOWERS/Memoize-ExpireLRU-0.55.tar.gz provides: @@ -5643,6 +5762,128 @@ DISTRIBUTIONS Scalar::Util 1.48 Test::More 0.96 Test::utf8 0 + OAuth-Lite2-0.11 + pathname: R/RI/RITOU/OAuth-Lite2-0.11.tar.gz + provides: + OAuth::Lite2 0.11 + OAuth::Lite2::Agent undef + OAuth::Lite2::Agent::Dump undef + OAuth::Lite2::Agent::PSGIMock undef + OAuth::Lite2::Agent::Strict undef + OAuth::Lite2::Client::ClientCredentials undef + OAuth::Lite2::Client::Error undef + OAuth::Lite2::Client::Error::InsecureRequest undef + OAuth::Lite2::Client::Error::InsecureResponse undef + OAuth::Lite2::Client::Error::InvalidResponse undef + OAuth::Lite2::Client::ExternalService undef + OAuth::Lite2::Client::ServerState undef + OAuth::Lite2::Client::StateResponseParser undef + OAuth::Lite2::Client::Token undef + OAuth::Lite2::Client::TokenResponseParser undef + OAuth::Lite2::Client::UsernameAndPassword undef + OAuth::Lite2::Client::WebServer undef + OAuth::Lite2::Formatter undef + OAuth::Lite2::Formatter::FormURLEncoded undef + OAuth::Lite2::Formatter::JSON undef + OAuth::Lite2::Formatter::Text undef + OAuth::Lite2::Formatter::XML undef + OAuth::Lite2::Formatters undef + OAuth::Lite2::Model::AccessToken undef + OAuth::Lite2::Model::AuthInfo undef + OAuth::Lite2::Model::ServerState undef + OAuth::Lite2::ParamMethod undef + OAuth::Lite2::ParamMethod::AuthHeader undef + OAuth::Lite2::ParamMethod::FormEncodedBody undef + OAuth::Lite2::ParamMethod::URIQueryParameter undef + OAuth::Lite2::ParamMethods undef + OAuth::Lite2::Server::Context undef + OAuth::Lite2::Server::DataHandler undef + OAuth::Lite2::Server::Endpoint::Token undef + OAuth::Lite2::Server::Error undef + OAuth::Lite2::Server::Error::AccessDenied undef + OAuth::Lite2::Server::Error::ExpiredToken undef + OAuth::Lite2::Server::Error::ExpiredTokenLegacy undef + OAuth::Lite2::Server::Error::InsufficientScope undef + OAuth::Lite2::Server::Error::InvalidClient undef + OAuth::Lite2::Server::Error::InvalidGrant undef + OAuth::Lite2::Server::Error::InvalidRequest undef + OAuth::Lite2::Server::Error::InvalidScope undef + OAuth::Lite2::Server::Error::InvalidServerState undef + OAuth::Lite2::Server::Error::InvalidToken undef + OAuth::Lite2::Server::Error::RedirectURIMismatch undef + OAuth::Lite2::Server::Error::ServerError undef + OAuth::Lite2::Server::Error::TemporarilyUnavailable undef + OAuth::Lite2::Server::Error::UnauthorizedClient undef + OAuth::Lite2::Server::Error::UnsupportedGrantType undef + OAuth::Lite2::Server::Error::UnsupportedResourceType undef + OAuth::Lite2::Server::Error::UnsupportedResponseType undef + OAuth::Lite2::Server::GrantHandler undef + OAuth::Lite2::Server::GrantHandler::AuthorizationCode undef + OAuth::Lite2::Server::GrantHandler::ClientCredentials undef + OAuth::Lite2::Server::GrantHandler::ExternalService undef + OAuth::Lite2::Server::GrantHandler::GroupingRefreshToken undef + OAuth::Lite2::Server::GrantHandler::Password undef + OAuth::Lite2::Server::GrantHandler::RefreshToken undef + OAuth::Lite2::Server::GrantHandler::ServerState undef + OAuth::Lite2::Server::GrantHandlers undef + OAuth::Lite2::Signer undef + OAuth::Lite2::Signer::Algorithm undef + OAuth::Lite2::Signer::Algorithm::HMAC_SHA1 undef + OAuth::Lite2::Signer::Algorithm::HMAC_SHA256 undef + OAuth::Lite2::Signer::Algorithms undef + OAuth::Lite2::Util undef + Plack::Middleware::Auth::OAuth2::ProtectedResource undef + requirements: + Class::Accessor::Fast 0.34 + Class::ErrorHandler 0.01 + Data::Dump 1.17 + Digest::SHA 5.48 + ExtUtils::MakeMaker 6.36 + Hash::MultiValue 0.08 + IO::String 1.08 + JSON::XS 0 + LWP::UserAgent 0 + Module::Build::Tiny 0.035 + Params::Validate 0.95 + Plack 0.09942 + Scalar::Util 1.23 + String::Random 0.22 + Test::More 0 + Try::Tiny 0.06 + URI 1.54 + XML::LibXML 1.7 + OIDC-Lite-0.10 + pathname: R/RI/RITOU/OIDC-Lite-0.10.tar.gz + provides: + OIDC::Lite 0.10 + OIDC::Lite::Client::Token undef + OIDC::Lite::Client::TokenResponseParser undef + OIDC::Lite::Client::WebServer undef + OIDC::Lite::Model::AuthInfo undef + OIDC::Lite::Model::IDToken undef + OIDC::Lite::Server::AuthorizationHandler undef + OIDC::Lite::Server::DataHandler undef + OIDC::Lite::Server::Endpoint::Token undef + OIDC::Lite::Server::GrantHandler::AuthorizationCode undef + OIDC::Lite::Server::GrantHandlers undef + OIDC::Lite::Server::Scope undef + OIDC::Lite::Util::JWT undef + Plack::Middleware::Auth::OIDC::ProtectedResource undef + requirements: + Class::Accessor::Fast 0.34 + Crypt::OpenSSL::RSA 0 + Data::Dump 1.17 + ExtUtils::MakeMaker 6.62 + JSON::WebToken 0.10 + JSON::XS 0 + MIME::Base64 3.11 + Module::Build 0.38 + OAuth::Lite2 0.10 + Params::Validate 0.95 + Test::Mock::LWP::Conditional 0 + Test::MockObject 0 + Test::More 0 + perl 5.008001 OLE-Storage_Lite-0.19 pathname: J/JM/JMCNAMARA/OLE-Storage_Lite-0.19.tar.gz provides: @@ -6723,6 +6964,18 @@ DISTRIBUTIONS requirements: ExtUtils::MakeMaker 0 Test::More 0 + String-Random-0.30 + pathname: S/SH/SHLOMIF/String-Random-0.30.tar.gz + provides: + String::Random 0.30 + requirements: + Carp 0 + Exporter 0 + Module::Build 0.28 + parent 0 + perl 5.006_001 + strict 0 + warnings 0 String-RewritePrefix-0.006 pathname: R/RJ/RJBS/String-RewritePrefix-0.006.tar.gz provides: @@ -7087,6 +7340,22 @@ DISTRIBUTIONS perl 5.006001 strict 0 warnings 0 + Test-Fake-HTTPD-0.08 + pathname: M/MA/MASAKI/Test-Fake-HTTPD-0.08.tar.gz + provides: + Test::Fake::HTTPD 0.08 + requirements: + Carp 0 + Exporter 0 + HTTP::Daemon 0 + HTTP::Message::PSGI 0 + Module::Build::Tiny 0.035 + Scalar::Util 1.14 + Test::SharedFork 0.29 + Test::TCP 0 + Time::HiRes 0 + URI 0 + perl 5.008001 Test-Fatal-0.010 pathname: R/RJ/RJBS/Test-Fatal-0.010.tar.gz provides: @@ -7171,6 +7440,22 @@ DISTRIBUTIONS requirements: ExtUtils::MakeMaker 6.03 Test::More 0 + Test-Mock-LWP-Conditional-0.04 + pathname: M/MA/MASAKI/Test-Mock-LWP-Conditional-0.04.tar.gz + provides: + Test::Mock::LWP::Conditional 0.04 + Test::Mock::LWP::Conditional::Stubs undef + requirements: + Class::Method::Modifiers 0 + LWP::UserAgent 0 + Math::Random::Secure 0 + Module::Build::Tiny 0.035 + Scalar::Util 1.14 + Sub::Install 0 + Test::Fake::HTTPD 0.03 + Test::More 0.98 + Test::UseAllModules 0 + perl 5.008001 Test-MockModule-0.11 pathname: G/GF/GFRANKS/Test-MockModule-0.11.tar.gz provides: @@ -7423,6 +7708,16 @@ DISTRIBUTIONS strict 0 version 0 warnings 0 + Test-UseAllModules-0.17 + pathname: I/IS/ISHIGAKI/Test-UseAllModules-0.17.tar.gz + provides: + Test::UseAllModules 0.17 + requirements: + Exporter 0 + ExtUtils::MakeMaker 0 + ExtUtils::Manifest 0 + Test::Builder 0.30 + Test::More 0.60 Test-WWW-Mechanize-1.44 pathname: P/PE/PETDANCE/Test-WWW-Mechanize-1.44.tar.gz provides: @@ -7764,6 +8059,16 @@ DISTRIBUTIONS Exporter::Tiny 0.026 ExtUtils::MakeMaker 6.17 perl 5.006001 + Types-Serialiser-1.0 + pathname: M/ML/MLEHMANN/Types-Serialiser-1.0.tar.gz + provides: + JSON::PP::Boolean 1.0 + Types::Serialiser 1.0 + Types::Serialiser::BooleanBase 1.0 + Types::Serialiser::Error 1.0 + requirements: + ExtUtils::MakeMaker 0 + common::sense 0 UNIVERSAL-can-1.20140328 pathname: C/CH/CHROMATIC/UNIVERSAL-can-1.20140328.tar.gz provides: @@ -8265,6 +8570,12 @@ DISTRIBUTIONS requirements: ExtUtils::MakeMaker 0 perl 5.008001 + common-sense-3.74 + pathname: M/ML/MLEHMANN/common-sense-3.74.tar.gz + provides: + common::sense 3.74 + requirements: + ExtUtils::MakeMaker 0 gettext-1.05 pathname: P/PV/PVANDRY/gettext-1.05.tar.gz provides: diff --git a/db/downgrade_0068---0067.sql b/db/downgrade_0068---0067.sql new file mode 100644 index 000000000..c12c0374b --- /dev/null +++ b/db/downgrade_0068---0067.sql @@ -0,0 +1,3 @@ +begin; +alter table users drop column oidc_ids; +commit; diff --git a/db/schema.sql b/db/schema.sql index 93d73ab00..a211ef50d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -35,6 +35,7 @@ create table users ( title text, twitter_id bigint unique, facebook_id bigint unique, + oidc_ids text ARRAY, area_ids integer ARRAY, extra text ); diff --git a/db/schema_0068-oidc_login.sql b/db/schema_0068-oidc_login.sql new file mode 100644 index 000000000..aadbef4da --- /dev/null +++ b/db/schema_0068-oidc_login.sql @@ -0,0 +1,3 @@ +begin; +alter table users add column oidc_ids text ARRAY; +commit; diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index c194045b9..964d8f19a 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') ] ); } @@ -180,10 +179,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 +216,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/) { + $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_}; + } +} + =head2 token Handle the 'email_sign_in' tokens. Find the account for the email address @@ -272,9 +282,21 @@ 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' ); + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } + + # send the user to their page $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] ); } @@ -429,6 +451,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') { diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm index 097cac984..aa3177163 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,144 @@ 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; + } + + $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; + } 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', []); + } + + # 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 +313,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/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index 120467905..a19c43af8 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -803,10 +803,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'. @@ -938,6 +945,11 @@ 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 eq 'button' && $c->get_param('report_anonymously'); + if ($anon_button) { + $c->stash->{contributing_as_anonymous_user} = 1; + } # set some simple bool values (note they get inverted) if ($c->stash->{contributing_as_body}) { @@ -1129,12 +1141,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 @@ -1154,8 +1167,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}; @@ -1179,7 +1197,6 @@ 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}; } @@ -1197,10 +1214,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 { @@ -1308,7 +1323,19 @@ 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 OIDC logout redirection happens, if applicable + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } } if ($problem->user->email_verified) { $c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' ); @@ -1368,11 +1395,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 @@ -1531,6 +1554,7 @@ sub create_reporter_alert : Private { my ( $self, $c ) = @_; 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( { diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index cbedf7a01..1825286ca 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -244,8 +244,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') || ''; } @@ -366,7 +365,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}; @@ -404,10 +403,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 +437,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 ) { @@ -585,8 +578,18 @@ 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 OIDC logout redirection happens, if applicable + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } } if ($comment->user->email_verified) { diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index eaf27e3bc..a6c6f34c4 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -503,6 +503,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. @@ -1058,8 +1071,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 @@ -1264,7 +1279,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/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index 794c3dec6..b59c8990b 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -273,10 +273,9 @@ see Buckinghamshire or Lincolnshire for an example. sub lookup_site_code { my $self = shift; my $row = shift; - my $buffer = shift; + my $field = shift; - my $cfg = $self->lookup_site_code_config; - $cfg->{buffer} = $buffer if $buffer; + my $cfg = $self->lookup_site_code_config($field); my ($x, $y) = $row->local_coords; my $features = $self->_fetch_features($cfg, $x, $y); @@ -288,17 +287,7 @@ sub _fetch_features { my $buffer = $cfg->{buffer}; my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer); - my $uri = URI->new($cfg->{url}); - $uri->query_form( - REQUEST => "GetFeature", - SERVICE => "WFS", - SRSNAME => $cfg->{srsname}, - TYPENAME => $cfg->{typename}, - VERSION => "1.1.0", - outputformat => "geojson", - BBOX => "$w,$s,$e,$n" - ); - + my $uri = $self->_fetch_features_url($cfg, $w, $s, $e,$n); my $response = get($uri) or return; my $j = JSON->new->utf8->allow_nonref; @@ -313,6 +302,24 @@ sub _fetch_features { return $j->{features}; } +sub _fetch_features_url { + my ($self, $cfg, $w, $s, $e, $n) = @_; + + my $uri = URI->new($cfg->{url}); + $uri->query_form( + REQUEST => "GetFeature", + SERVICE => "WFS", + SRSNAME => $cfg->{srsname}, + TYPENAME => $cfg->{typename}, + VERSION => "1.1.0", + outputformat => "geojson", + BBOX => "$w,$s,$e,$n" + ); + + return $uri; +} + + sub _nearest_feature { my ($self, $cfg, $x, $y, $features) = @_; @@ -321,16 +328,25 @@ sub _nearest_feature { my $site_code = ''; my $nearest; + # We shouldn't receive anything aside from these geometry types, but belt and braces. + my $accept_types = $cfg->{accept_types} || { + LineString => 1, + MultiLineString => 1 + }; + for my $feature ( @{$features || []} ) { next unless $cfg->{accept_feature}($feature); - - # 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'; + next unless $accept_types->{$feature->{geometry}->{type}}; 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 ] ]); + } foreach my $coordinates (@linestrings) { for (my $i=0; $i<@$coordinates-1; $i++) { diff --git a/perllib/FixMyStreet/Cobrand/Westminster.pm b/perllib/FixMyStreet/Cobrand/Westminster.pm new file mode 100644 index 000000000..3d99e59c4 --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/Westminster.pm @@ -0,0 +1,148 @@ +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 updates_disallowed { + my $self = shift; + my $c = $self->{c}; + + # Only WCC 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 ( $staff || $superuser ) ? 0 : 1; + } + +sub suppress_reporter_alerts { 1 } + +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'; + + my $extra = $row->get_extra_fields; + + # 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 }; + } + } + + $row->set_extra_fields(@$extra); +} + +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.staging.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, $w, $s, $e, $n) = @_; + + # 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 => "$w,$s,$e,$n", + ); + + return $cfg->{proxy_url} . "?" . $uri->as_string; +} + +1; diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index fc651b4d1..4ea7524bb 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"]); @@ -129,8 +131,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:qtmzA7ywVkyQpjLh1ienNg +# 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. @@ -142,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' ]; @@ -534,6 +537,7 @@ sub anonymize_account { title => undef, twitter_id => undef, facebook_id => undef, + oidc_ids => undef, }); } @@ -654,4 +658,20 @@ sub in_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/OIDC/Lite/Client/IDTokenResponseParser.pm b/perllib/OIDC/Lite/Client/IDTokenResponseParser.pm new file mode 100644 index 000000000..41db1bbdb --- /dev/null +++ b/perllib/OIDC/Lite/Client/IDTokenResponseParser.pm @@ -0,0 +1,72 @@ +package OIDC::Lite::Client::IDTokenResponseParser; + +use strict; +use warnings; + +use Try::Tiny qw/try catch/; +use OIDC::Lite::Model::IDToken; +use OAuth::Lite2::Formatters; +use OAuth::Lite2::Client::Error; + +=head1 NAME + +OIDC::Lite::Client::IDTokenResponseParser - parse id_token JWT into an +L<OIDC::Lite::Model::IDToken> object. + +Acts the same as L<OIDC::Lite::Client::TokenResponseParser> but looks for an +id_token in the HTTP response instead of access_token. + +=cut + +sub new { + bless {}, $_[0]; +} + +sub parse { + my ($self, $http_res) = @_; + + my $formatter = + OAuth::Lite2::Formatters->get_formatter_by_type( + $http_res->content_type); + + my $token; + + if ($http_res->is_success) { + + OAuth::Lite2::Client::Error::InvalidResponse->throw( + message => sprintf(q{Invalid response content-type: %s}, + $http_res->content_type||'') + ) unless $formatter; + + my $result = try { + return $formatter->parse($http_res->content); + } catch { + OAuth::Lite2::Client::Error::InvalidResponse->throw( + message => sprintf(q{Invalid response format: %s}, $_), + ); + }; + + OAuth::Lite2::Client::Error::InvalidResponse->throw( + message => sprintf("Response doesn't include 'id_token'") + ) unless exists $result->{id_token}; + + $token = OIDC::Lite::Model::IDToken->load($result->{id_token}); + + } else { + + my $errmsg = $http_res->content || $http_res->status_line; + if ($formatter && $http_res->content) { + try { + my $result = $formatter->parse($http_res->content); + $errmsg = $result->{error} + if exists $result->{error}; + } catch { + return OAuth::Lite2::Client::Error::InvalidResponse->throw; + }; + } + OAuth::Lite2::Client::Error::InvalidResponse->throw( message => $errmsg ); + } + return $token; +} + +1; diff --git a/perllib/OIDC/Lite/Client/WebServer/Azure.pm b/perllib/OIDC/Lite/Client/WebServer/Azure.pm new file mode 100644 index 000000000..b19dce90e --- /dev/null +++ b/perllib/OIDC/Lite/Client/WebServer/Azure.pm @@ -0,0 +1,39 @@ +package OIDC::Lite::Client::WebServer::Azure; + +use strict; +use warnings; +use parent 'OIDC::Lite::Client::WebServer'; + +use OIDC::Lite::Client::IDTokenResponseParser; + +=head1 NAME + +OIDC::Lite::Client::WebServer::Azure - extension to auth against Azure AD B2C + +OIDC::Lite doesn't appear to support the authorisation code flow to get an +ID token - only an access token. Azure returns all its claims in the id_token +and doesn't support a UserInfo endpoint, so this extension adds support for +parsing the id_token when calling get_access_token. + +=cut + +=head2 new + +Overrides response_parser so that get_access_token returns a +L<OIDC::Lite::Model::IDToken> object. + +NB this does not perform any verification of the id_token. It's assumed to be +safe as it's come directly from the OpenID IdP and not an untrusted user's +browser. + +=cut + +sub new { + my $self = shift->next::method(@_); + + $self->{response_parser} = OIDC::Lite::Client::IDTokenResponseParser->new; + + return $self; +} + +1; diff --git a/perllib/Open311.pm b/perllib/Open311.pm index ebf3ee987..8ef1a4f2c 100644 --- a/perllib/Open311.pm +++ b/perllib/Open311.pm @@ -144,6 +144,8 @@ sub _populate_service_request_params { $params->{phone} = $problem->user->phone if $problem->user->phone; $params->{email} = $problem->user->email if $problem->user->email; + $params->{account_id} = $extra->{account_id} if defined $extra->{account_id}; + # Some endpoints don't follow the Open311 spec correctly and require an # email address for service requests. if ($self->always_send_email && !$params->{email}) { diff --git a/t/Mock/OpenIDConnect.pm b/t/Mock/OpenIDConnect.pm new file mode 100644 index 000000000..079c354fc --- /dev/null +++ b/t/Mock/OpenIDConnect.pm @@ -0,0 +1,77 @@ +package t::Mock::OpenIDConnect; + +use JSON::MaybeXS; +use Web::Simple; +use DateTime; +use MIME::Base64 qw(encode_base64); +use MooX::Types::MooseLike::Base qw(:all); + +has json => ( + is => 'lazy', + default => sub { + JSON->new->pretty->allow_blessed->convert_blessed; + }, +); + +has returns_email => ( + is => 'rw', + isa => Bool, + default => 1, +); + +sub dispatch_request { + my $self = shift; + + sub (GET + /oauth2/v2.0/authorize + ?*) { + my ($self) = @_; + return [ 200, [ 'Content-Type' => 'text/html' ], [ 'OpenID Connect login page' ] ]; + }, + + sub (GET + /oauth2/v2.0/logout + ?*) { + my ($self) = @_; + return [ 200, [ 'Content-Type' => 'text/html' ], [ 'OpenID Connect logout page' ] ]; + }, + + sub (POST + /oauth2/v2.0/token + ?*) { + my ($self) = @_; + my $header = { + typ => "JWT", + alg => "RS256", + kid => "XXXfakeKEY1234", + }; + my $now = DateTime->now->epoch; + my $payload = { + exp => $now + 3600, + nbf => $now, + ver => "1.0", + iss => "https://login.example.org/12345-6789-4321-abcd-12309812309/v2.0/", + sub => "my_cool_user_id", + aud => "example_client_id", + iat => $now, + auth_time => $now, + given_name => "Andy", + family_name => "Dwyer", + tfp => "B2C_1_default", + extension_CrmContactId => "1c304134-ef12-c128-9212-123908123901", + nonce => 'MyAwesomeRandomValue', + }; + $payload->{emails} = ['oidc@example.org'] if $self->returns_email; + my $signature = "dummy"; + my $id_token = join(".", ( + encode_base64($self->json->encode($header), ''), + encode_base64($self->json->encode($payload), ''), + encode_base64($signature, '') + )); + my $data = { + id_token => $id_token, + token_type => "Bearer", + not_before => $now, + id_token_expires_in => 3600, + profile_info => encode_base64($self->json->encode({}), ''), + }; + my $json = $self->json->encode($data); + return [ 200, [ 'Content-Type' => 'application/json' ], [ $json ] ]; + }, +} + +__PACKAGE__->run_if_script; diff --git a/t/app/controller/auth_social.t b/t/app/controller/auth_social.t index 75eabfc43..160d46f8f 100644 --- a/t/app/controller/auth_social.t +++ b/t/app/controller/auth_social.t @@ -5,17 +5,18 @@ use JSON::MaybeXS; use t::Mock::Facebook; use t::Mock::Twitter; +use t::Mock::OpenIDConnect; use FixMyStreet::TestMech; my $mech = FixMyStreet::TestMech->new; - + # disable info logs for this test run FixMyStreet::App->log->disable('info'); END { FixMyStreet::App->log->enable('info'); } my $body = $mech->create_body_ok(2504, 'Westminster Council'); -my ($report) = $mech->create_problems_for_body(1, $body->id, 'Test'); +my ($report) = $mech->create_problems_for_body(1, $body->id, 'My Test Report'); my $contact = $mech->create_contact_ok( body_id => $body->id, category => 'Damaged bin', email => 'BIN', @@ -30,44 +31,101 @@ my $contact2 = $mech->create_contact_ok( body_id => $body->id, category => 'Whatever', email => 'WHATEVER', ); -FixMyStreet::override_config { - FACEBOOK_APP_ID => 'facebook-app-id', - TWITTER_KEY => 'twitter-key', - ALLOWED_COBRANDS => [ { fixmystreet => '.' } ], - MAPIT_URL => 'http://mapit.uk/', -}, sub { +my $resolver = Test::MockModule->new('Email::Valid'); +my $social = Test::MockModule->new('FixMyStreet::App::Controller::Auth::Social'); +$social->mock('generate_nonce', sub { 'MyAwesomeRandomValue' }); + +for my $test ( + { + type => 'facebook', + config => { + FACEBOOK_APP_ID => 'facebook-app-id', + ALLOWED_COBRANDS => [ { fixmystreet => '.' } ], + MAPIT_URL => 'http://mapit.uk/', + }, + update => 1, + email => 'facebook@example.org', + uid => 123456789, + mock => 't::Mock::Facebook', + mock_hosts => ['www.facebook.com', 'graph.facebook.com'], + host => 'www.facebook.com', + error_callback => '/auth/Facebook?error_code=ERROR', + success_callback => '/auth/Facebook?code=response-code', + redirect_pattern => qr{facebook\.com.*dialog/oauth.*facebook-app-id}, +}, { + type => 'oidc', + config => { + ALLOWED_COBRANDS => [ { westminster => '.' } ], + MAPIT_URL => 'http://mapit.uk/', + COBRAND_FEATURES => { + oidc_login => { + westminster => { + client_id => 'example_client_id', + secret => 'example_secret_key', + auth_uri => 'http://oidc.example.org/oauth2/v2.0/authorize', + token_uri => 'http://oidc.example.org/oauth2/v2.0/token', + logout_uri => 'http://oidc.example.org/oauth2/v2.0/logout', + display_name => 'MyWestminster' + } + } + } + }, + email => 'oidc@example.org', + uid => "westminster:example_client_id:my_cool_user_id", + mock => 't::Mock::OpenIDConnect', + mock_hosts => ['oidc.example.org'], + host => 'oidc.example.org', + error_callback => '/auth/OIDC?error=ERROR', + success_callback => '/auth/OIDC?code=response-code&state=login', + redirect_pattern => qr{oidc\.example\.org/oauth2/v2\.0/authorize}, + logout_redirect_pattern => qr{oidc\.example\.org/oauth2/v2\.0/logout}, + user_extras => [ + [westminster_account_id => "1c304134-ef12-c128-9212-123908123901"], + ], +} +) { -my $fb_email = 'facebook@example.org'; -my $fb_uid = 123456789; +FixMyStreet::override_config $test->{config}, sub { -my $resolver = Test::MockModule->new('Email::Valid'); -$resolver->mock('address', sub { 'facebook@example.org' }); +$resolver->mock('address', sub { $test->{email} }); -for my $fb_state ( 'refused', 'no email', 'existing UID', 'okay' ) { +for my $state ( 'refused', 'no email', 'existing UID', 'okay' ) { for my $page ( 'my', 'report', 'update' ) { - subtest "test FB '$fb_state' login for page '$page'" => sub { + next if $page eq 'update' && !$test->{update}; + + subtest "test $test->{type} '$state' login for page '$page'" => sub { # Lots of user changes happening here, make sure we don't confuse # Catalyst with a cookie session user that no longer exists $mech->log_out_ok; $mech->cookie_jar({}); - if ($fb_state eq 'existing UID') { - my $user = $mech->create_user_ok($fb_email); - $user->update({ facebook_id => $fb_uid }); + if ($state eq 'existing UID') { + my $user = $mech->create_user_ok($test->{email}); + if ($test->{type} eq 'facebook') { + $user->update({ facebook_id => $test->{uid} }); + } elsif ($test->{type} eq 'oidc') { + $user->update({ oidc_ids => [ $test->{uid} ] }); + } } else { - $mech->delete_user($fb_email); + $mech->delete_user($test->{email}); + } + if ($page eq 'my' && $state eq 'existing UID') { + $report->update({ user_id => FixMyStreet::App->model( 'DB::User' )->find( { email => $test->{email} } )->id }); + } else { + $report->update({ user_id => FixMyStreet::App->model( 'DB::User' )->find( { email => 'test@example.com' } )->id }); } - # Set up a mock to catch (most, see below) requests to Facebook - my $fb = t::Mock::Facebook->new; - $fb->returns_email(0) if $fb_state eq 'no email' || $fb_state eq 'existing UID'; - LWP::Protocol::PSGI->register($fb->to_psgi_app, host => 'www.facebook.com'); - LWP::Protocol::PSGI->register($fb->to_psgi_app, host => 'graph.facebook.com'); + # Set up a mock to catch (most, see below) requests to the OAuth API + my $mock_api = $test->{mock}->new; + $mock_api->returns_email(0) if $state eq 'no email' || $state eq 'existing UID'; + for my $host (@{ $test->{mock_hosts} }) { + LWP::Protocol::PSGI->register($mock_api->to_psgi_app, host => $host); + } # Due to https://metacpan.org/pod/Test::WWW::Mechanize::Catalyst#External-Redirects-and-allow_external - # the redirect to Facebook's OAuth page can mess up the session - # cookie. So let's pretend we always on www.facebook.com, which + # the redirect to the OAuth page can mess up the session + # cookie. So let's pretend we're always on the API host, which # sorts that out. - $mech->host('www.facebook.com'); + $mech->host($test->{host}); # Fetch the page with the form via which we wish to log in my $fields; @@ -91,20 +149,23 @@ for my $fb_state ( 'refused', 'no email', 'existing UID', 'okay' ) { update => 'Test update', }; } - $mech->submit_form(with_fields => $fields, button => 'facebook_sign_in'); + $mech->submit_form(with_fields => $fields, button => 'social_sign_in'); # As well as the cookie issue above, caused by this external # redirect rewriting the host, the redirect gets handled directly # by Catalyst, not our mocked handler, so will be a 404. Check # the redirect happened instead. - is $mech->res->previous->code, 302, 'FB button redirected'; - like $mech->res->previous->header('Location'), qr{facebook\.com.*dialog/oauth.*facebook-app-id}, 'FB redirect to oauth URL'; - - # Okay, now call the callback Facebook would send us to - if ($fb_state eq 'refused') { - $mech->get_ok('/auth/Facebook?error_code=ERROR'); + is $mech->res->previous->code, 302, "$test->{type} button redirected"; + like $mech->res->previous->header('Location'), $test->{redirect_pattern}, "$test->{type} redirect to oauth URL"; + + # Okay, now call the callback we'd be sent to + # NB: for OIDC these should be post_ok, but that doesn't work because + # the session cookie doesn't seem to be included (related to the + # cookie issue above perhaps). + if ($state eq 'refused') { + $mech->get_ok($test->{error_callback}); } else { - $mech->get_ok('/auth/Facebook?code=response-code'); + $mech->get_ok($test->{success_callback}); } # Check we're showing the right form, regardless of what came back @@ -115,14 +176,14 @@ for my $fb_state ( 'refused', 'no email', 'existing UID', 'okay' ) { $mech->content_contains('/report/update'); } - if ($fb_state eq 'refused') { - $mech->content_contains('Sorry, we could not log you in. Please fill in the form below.'); + if ($state eq 'refused') { + $mech->content_contains('Sorry, we could not log you in.'); $mech->not_logged_in_ok; - } elsif ($fb_state eq 'no email') { + } elsif ($state eq 'no email') { $mech->content_contains('We need your email address, please give it below.'); # We don't have an email, so check that we can still submit it, # and the ID carries through the confirmation - $fields->{username} = $fb_email; + $fields->{username} = $test->{email}; $fields->{name} = 'Ffion Tester' unless $page eq 'my'; $mech->submit_form(with_fields => $fields, $page eq 'my' ? (button => 'sign_in_by_code') : ()); $mech->content_contains('Nearly done! Now check your email'); @@ -131,26 +192,77 @@ for my $fb_state ( 'refused', 'no email', 'existing UID', 'okay' ) { $mech->clear_emails_ok; ok $url, "extracted confirm url '$url'"; - my $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $fb_email } ); + my $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $test->{email} } ); if ($page eq 'my') { is $user, undef, 'No user yet exists'; } else { - is $user->facebook_id, undef, 'User has no facebook ID'; + if ($test->{type} eq 'facebook') { + is $user->facebook_id, undef, 'User has no facebook ID'; + } elsif ($test->{type} eq 'oidc') { + is $user->oidc_ids, undef, 'User has no OIDC IDs'; + } } $mech->get_ok( $url ); - $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $fb_email } ); - is $user->facebook_id, $fb_uid, 'User now has correct facebook ID'; + $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $test->{email} } ); + if ($test->{type} eq 'facebook') { + is $user->facebook_id, $test->{uid}, 'User now has correct facebook ID'; + } elsif ($test->{type} eq 'oidc') { + is_deeply $user->oidc_ids, [ $test->{uid} ], 'User now has correct OIDC IDs'; + } + if ($test->{user_extras}) { + for my $extra (@{ $test->{user_extras} }) { + my ($k, $v) = @$extra; + is $user->get_extra_metadata($k), $v, "User has correct $k extra field"; + } + } } elsif ($page ne 'my') { # /my auth login goes directly there, no message like this $mech->content_contains('You have successfully signed in; please check and confirm your details are accurate'); $mech->logged_in_ok; + if ($test->{user_extras}) { + my $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $test->{email} } ); + for my $extra (@{ $test->{user_extras} }) { + my ($k, $v) = @$extra; + is $user->get_extra_metadata($k), $v, "User has correct $k extra field"; + } + } } else { is $mech->uri->path, '/my', 'Successfully on /my page'; + if ($test->{user_extras}) { + my $user = FixMyStreet::App->model( 'DB::User' )->find( { email => $test->{email} } ); + for my $extra (@{ $test->{user_extras} }) { + my ($k, $v) = @$extra; + is $user->get_extra_metadata($k), $v, "User has correct $k extra field"; + } + } + if ($state eq 'existing UID') { + my $report_id = $report->id; + $mech->content_contains( $report->title ); + $mech->content_contains( "/report/$report_id" ); + } + } + + $mech->get('/auth/sign_out'); + if ($test->{type} eq 'oidc' && $state ne 'refused' && $state ne 'no email') { + # XXX the 'no email' situation is skipped because of some confusion + # with the hosts/sessions that I've not been able to get to the bottom of. + # The code does behave as expected when testing manually, however. + is $mech->res->previous->code, 302, "$test->{type} sign out redirected"; + like $mech->res->previous->header('Location'), $test->{logout_redirect_pattern}, "$test->{type} sign out redirect to oauth logout URL"; } + $mech->not_logged_in_ok; } } } +} +}; + +FixMyStreet::override_config { + TWITTER_KEY => 'twitter-key', + ALLOWED_COBRANDS => [ { fixmystreet => '.' } ], + MAPIT_URL => 'http://mapit.uk/', +}, sub { $resolver->mock('address', sub { 'twitter@example.org' }); @@ -204,7 +316,7 @@ for my $tw_state ( 'refused', 'existing UID', 'no email' ) { update => 'Test update', }; } - $mech->submit_form(with_fields => $fields, button => 'twitter_sign_in'); + $mech->submit_form(with_fields => $fields, button => 'social_sign_in'); # As well as the cookie issue above, caused by this external # redirect rewriting the host, the redirect gets handled directly diff --git a/t/app/controller/index.t b/t/app/controller/index.t index bd268b3d7..3f3aed48e 100644 --- a/t/app/controller/index.t +++ b/t/app/controller/index.t @@ -91,6 +91,13 @@ subtest "prefilters /around if user has categories" => sub { $mech->content_contains("Cows,Potholes"); }; +subtest "prefilters /around if filter_category given in URL" => sub { + $mech->get_ok('/?filter_category=MyUniqueTestCategory&filter_group=MyUniqueTestGroup'); + # NB can't use visible_form_values because fields are hidden + $mech->content_contains("MyUniqueTestCategory"); + $mech->content_contains("MyUniqueTestGroup"); +}; + END { done_testing(); } diff --git a/t/app/controller/report_new_anon.t b/t/app/controller/report_new_anon.t new file mode 100644 index 000000000..e9ce23c44 --- /dev/null +++ b/t/app/controller/report_new_anon.t @@ -0,0 +1,164 @@ +package FixMyStreet::Cobrand::AnonAllowed; +use parent 'FixMyStreet::Cobrand::FixMyStreet'; +sub allow_anonymous_reports { 1 } +sub anonymous_account { { email => 'anon@example.org', name => 'Anonymous' } } + +package FixMyStreet::Cobrand::AnonAllowedByButton; +use parent 'FixMyStreet::Cobrand::FixMyStreet'; +sub allow_anonymous_reports { 'button' } +sub anonymous_account { { email => 'anonbutton@example.org', name => 'Anonymous Button' } } + +package main; + +use FixMyStreet::TestMech; +use FixMyStreet::App; + +# disable info logs for this test run +FixMyStreet::App->log->disable('info'); +END { FixMyStreet::App->log->enable('info'); } + +my $mech = FixMyStreet::TestMech->new; + +my $body = $mech->create_body_ok(2651, 'Edinburgh'); +my $contact1 = $mech->create_contact_ok( + body_id => $body->id, + category => 'Street lighting', + email => 'highways@example.com', +); +my $contact2 = $mech->create_contact_ok( + body_id => $body->id, + category => 'Trees', + email => 'trees@example.com', +); + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'anonallowed', + MAPIT_URL => 'http://mapit.uk/', +}, sub { + +subtest "check form errors when anonymous account is on" => sub { + $mech->get_ok('/around'); + + $mech->submit_form_ok( { with_fields => { pc => 'EH1 1BB' } }, "submit location" ); + $mech->follow_link_ok( { text_regex => qr/skip this step/i, }, "follow 'skip this step' link" ); + $mech->submit_form_ok( { with_fields => { category => "Street lighting" } }, "submit form" ); + + my @errors = ( + 'Please enter a subject', + 'Please enter some details', + # No user errors + ); + is_deeply [ sort @{$mech->page_errors} ], [ sort @errors ], "check errors"; +}; + +subtest "test report creation anonymously" => sub { + $mech->get_ok('/around'); + $mech->submit_form_ok( { with_fields => { pc => 'EH1 1BB', } }, "submit location" ); + $mech->follow_link_ok( { text_regex => qr/skip this step/i, }, "follow 'skip this step' link" ); + $mech->submit_form_ok( + { + button => 'submit_register', + with_fields => { + title => 'Test Report', + detail => 'Test report details.', + name => 'Joe Bloggs', + may_show_name => '1', + category => 'Street lighting', + } + }, + "submit good details" + ); + $mech->content_contains('Thank you'); + + is_deeply $mech->page_errors, [], "check there were no errors"; + + my $report = FixMyStreet::DB->resultset("Problem")->first; + ok $report, "Found the report"; + + is $report->state, 'confirmed', "report confirmed"; + $mech->get_ok( '/report/' . $report->id ); + + is $report->bodies_str, $body->id; + is $report->name, 'Anonymous'; + is $report->anonymous, 0; # Doesn't change behaviour here, but uses anon account's name always + + my $alert = FixMyStreet::App->model('DB::Alert')->find( { + user => $report->user, + alert_type => 'new_updates', + parameter => $report->id, + } ); + is $alert, undef, "no alert created"; + + $mech->not_logged_in_ok; +}; + +}; + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'anonallowedbybutton', + MAPIT_URL => 'http://mapit.uk/', +}, sub { + +subtest "test report creation anonymously by button" => sub { + $mech->get_ok('/around'); + $mech->submit_form_ok( { with_fields => { pc => 'EH1 1BB', } }, "submit location" ); + $mech->follow_link_ok( { text_regex => qr/skip this step/i, }, "follow 'skip this step' link" ); + $mech->submit_form_ok( + { + button => 'submit_register', + with_fields => { + title => 'Test Report', + detail => 'Test report details.', + name => 'Joe Bloggs', + may_show_name => '1', + category => 'Street lighting', + } + }, + "submit good details" + ); + + is_deeply $mech->page_errors, [ + 'Please enter your email' + ], "check there were no errors"; + + $mech->get_ok('/around'); + $mech->submit_form_ok( { with_fields => { pc => 'EH1 1BB', } }, "submit location" ); + $mech->follow_link_ok( { text_regex => qr/skip this step/i, }, "follow 'skip this step' link" ); + $mech->submit_form_ok( + { + button => 'report_anonymously', + with_fields => { + title => 'Test Report', + detail => 'Test report details.', + category => 'Street lighting', + } + }, + "submit good details" + ); + $mech->content_contains('Thank you'); + + is_deeply $mech->page_errors, [], "check there were no errors"; + + my $report = FixMyStreet::DB->resultset("Problem")->search({}, { order_by => { -desc => 'id' } })->first; + ok $report, "Found the report"; + + is $report->state, 'confirmed', "report confirmed"; + $mech->get_ok( '/report/' . $report->id ); + + is $report->bodies_str, $body->id; + is $report->name, 'Anonymous Button'; + is $report->anonymous, 1; # Doesn't change behaviour here, but uses anon account's name always + + my $alert = FixMyStreet::App->model('DB::Alert')->find( { + user => $report->user, + alert_type => 'new_updates', + parameter => $report->id, + } ); + is $alert, undef, "no alert created"; + + $mech->not_logged_in_ok; +}; + +}; + +done_testing(); diff --git a/t/app/model/user.t b/t/app/model/user.t index cbc0fe6cf..88b29ca84 100644 --- a/t/app/model/user.t +++ b/t/app/model/user.t @@ -75,6 +75,31 @@ subtest 'Check non-existent methods on user object die' => sub { ); }; +subtest 'OIDC ids can be manipulated correctly' => sub { + my $user = $problem->user; + + is $user->oidc_ids, undef, 'user starts with no OIDC ids'; + + $user->add_oidc_id("fixmystreet:1234:5678"); + is_deeply $user->oidc_ids, ["fixmystreet:1234:5678"], 'OIDC id added correctly'; + + $user->add_oidc_id("mycobrand:0123:abcd"); + is_deeply [ sort @{$user->oidc_ids} ], ["fixmystreet:1234:5678", "mycobrand:0123:abcd"], 'Second OIDC id added correctly'; + + $user->add_oidc_id("mycobrand:0123:abcd"); + is_deeply [ sort @{$user->oidc_ids} ], ["fixmystreet:1234:5678", "mycobrand:0123:abcd"], 'Adding existing OIDC id does not add duplicate'; + + $user->remove_oidc_id("mycobrand:0123:abcd"); + is_deeply $user->oidc_ids, ["fixmystreet:1234:5678"], 'OIDC id can be removed OK'; + + $user->remove_oidc_id("mycobrand:0123:abcd"); + is_deeply $user->oidc_ids, ["fixmystreet:1234:5678"], 'Removing non-existent OIDC id has no effect'; + + $user->remove_oidc_id("fixmystreet:1234:5678"); + is $user->oidc_ids, undef, 'Removing last OIDC id results in undef'; + +}; + done_testing(); sub create_update { diff --git a/t/cobrand/bucks.t b/t/cobrand/bucks.t index 6732eb29c..2d42dcd81 100644 --- a/t/cobrand/bucks.t +++ b/t/cobrand/bucks.t @@ -19,7 +19,7 @@ $mech->create_contact_ok(body_id => $district->id, category => 'Graffiti', email my $cobrand = Test::MockModule->new('FixMyStreet::Cobrand::Buckinghamshire'); $cobrand->mock('lookup_site_code', sub { - my ($self, $row, $buffer) = @_; + my ($self, $row) = @_; return "Road ID" if $row->latitude == 51.812244; }); diff --git a/t/cobrand/westminster.t b/t/cobrand/westminster.t new file mode 100644 index 000000000..2912f6ee5 --- /dev/null +++ b/t/cobrand/westminster.t @@ -0,0 +1,218 @@ +use CGI::Simple; +use Test::MockModule; +use FixMyStreet::TestMech; +use FixMyStreet::Script::Reports; + +ok( my $mech = FixMyStreet::TestMech->new, 'Created mech object' ); + +my $cobrand = Test::MockModule->new('FixMyStreet::Cobrand::Westminster'); +$cobrand->mock('lookup_site_code', sub { + my ($self, $row) = @_; + return "My USRN" if $row->latitude == 51.501009; +}); + +my $body = $mech->create_body_ok(2504, 'Westminster City Council', { + send_method => 'Open311', api_key => 'key', 'endpoint' => 'e', 'jurisdiction' => 'j' }); +my $superuser = $mech->create_user_ok( + 'superuser@example.com', + name => 'Test Superuser', + is_superuser => 1 +); +my $staff_user = $mech->create_user_ok( + 'westminster@example.com', + name => 'Test User', + from_body => $body +); +my ($report) = $mech->create_problems_for_body(1, $body->id, 'Title'); + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + COBRAND_FEATURES => { + oidc_login => { + westminster => { + client_id => 'example_client_id', + secret => 'example_secret_key', + auth_uri => 'http://oidc.example.org/oauth2/v2.0/authorize', + token_uri => 'http://oidc.example.org/oauth2/v2.0/token', + display_name => 'MyWestminster' + } + } + } +}, sub { + subtest 'Cobrand allows social auth' => sub { + my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker('westminster')->new(); + ok $cobrand->social_auth_enabled; + }; + + subtest 'Login button displayed correctly' => sub { + $mech->get_ok("/auth"); + $mech->content_contains("Sign in with MyWestminster"); + }; + + subtest 'Reports do not have update form' => sub { + $mech->get_ok('/report/' . $report->id); + $mech->content_lacks('Provide an update'); + }; +}; + +subtest 'Reports have an update form for superusers' => sub { + # Westminster cobrand disables email signin, so we have to + # login and *then* set the cobrand. + $mech->log_in_ok( $superuser->email ); + + FixMyStreet::override_config { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + }, sub { + $mech->get_ok('/report/' . $report->id); + $mech->content_contains('Provide an update'); + }; + + $mech->log_out_ok(); +}; + +subtest 'Reports have an update form for staff users' => sub { + $mech->log_in_ok( $staff_user->email ); + FixMyStreet::override_config { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + }, sub { + $mech->get_ok('/report/' . $report->id); + $mech->content_contains('Provide an update'); + }; + $mech->log_out_ok(); +}; + +for ( + { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + COBRAND_FEATURES => { + oidc_login => { + westminster => 0 + } + } + }, + { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + COBRAND_FEATURES => { + oidc_login => { + hounslow => { + client_id => 'example_client_id', + secret => 'example_secret_key', + auth_uri => 'http://oidc.example.org/oauth2/v2.0/authorize', + token_uri => 'http://oidc.example.org/oauth2/v2.0/token', + display_name => 'MyHounslow' + } + } + } + }, + { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', + } +) { + FixMyStreet::override_config $_, sub { + subtest 'Cobrand disallows social auth' => sub { + my $cobrand = FixMyStreet::Cobrand->get_class_for_moniker('westminster')->new(); + ok !$cobrand->social_auth_enabled; + }; + + subtest 'Login button not displayed' => sub { + $mech->get_ok("/auth"); + $mech->content_lacks("Login with MyWestminster"); + }; + }; +} + +FixMyStreet::DB->resultset('Problem')->delete_all; +$mech->create_contact_ok(body_id => $body->id, category => 'Abandoned bike', email => "BIKE"); +($report) = $mech->create_problems_for_body(1, $body->id, 'Bike', { + category => "Abandoned bike", cobrand => 'westminster', + latitude => 51.501009, longitude => -0.141588, areas => '2504', +}); + +FixMyStreet::override_config { + ALLOWED_COBRANDS => [ 'westminster' ], + MAPIT_URL => 'http://mapit.uk/', + STAGING_FLAGS => { send_reports => 1, skip_checks => 0 }, + COBRAND_FEATURES => { anonymous_account => { westminster => 'anon' } }, +}, sub { + subtest 'USRN set correctly' => sub { + my $test_data = FixMyStreet::Script::Reports::send(); + my $req = $test_data->{test_req_used}; + my $c = CGI::Simple->new($req->content); + is $c->param('service_code'), 'BIKE'; + is $c->param('attribute[USRN]'), 'My USRN'; + }; +}; + +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'westminster', + MAPIT_URL => 'http://mapit.uk/', +}, sub { + subtest 'No reporter alert created' => sub { + my $user = $mech->log_in_ok('test@example.org'); + $mech->get_ok('/'); + $mech->submit_form_ok( { with_fields => { pc => 'SW1A1AA' } }, "submit location" ); + $mech->follow_link_ok( { text_regex => qr/skip this step/i, }, "follow 'skip this step' link" ); + $mech->submit_form_ok( { with_fields => { + title => 'Title', detail => 'Detail', category => 'Abandoned bike', name => 'Test Example', + } }, 'submitted okay' ); + is $user->alerts->count, 0; + }; +}; + +my $westminster = FixMyStreet::Cobrand::Westminster->new; +subtest 'correct config returned for USRN/UPRN lookup' => sub { + my $actual = $westminster->lookup_site_code_config('USRN'); + delete $actual->{accept_feature}; # is_deeply doesn't like code + is_deeply $actual, { + buffer => 1000, + proxy_url => "https://tilma.staging.mysociety.org/resource-proxy/proxy.php", + url => "https://westminster.assets/40/query", + property => 'USRN', + }; + $actual = $westminster->lookup_site_code_config('UPRN'); + delete $actual->{accept_feature}; # is_deeply doesn't like code + is_deeply $actual, { + buffer => 1000, + proxy_url => "https://tilma.staging.mysociety.org/resource-proxy/proxy.php", + url => "https://westminster.assets/25/query", + property => 'UPRN', + accept_types => { + Point => 1 + }, + }; +}; + +subtest 'nearest UPRN returns correct point' => sub { + my $cfg = { + accept_feature => sub { 1 }, + property => 'UPRN', + accept_types => { + Point => 1, + }, + }; + my $features = [ + # A couple of incorrect geometry types to check they're ignored... + { geometry => { type => 'Polygon' } }, + { geometry => { type => 'LineString', + coordinates => [ [ 527735, 181004 ], [ 527755, 181004 ] ] }, + properties => { fid => '20100024' } }, + # And two points which are further away than the above linestring, + # the second of which is the closest to our testing point. + { geometry => { type => 'Point', + coordinates => [ 527795, 181024 ] }, + properties => { UPRN => '10012387122' } }, + { geometry => { type => 'Point', + coordinates => [ 527739, 181009 ] }, + properties => { UPRN => '10012387123' } }, + ]; + is $westminster->_nearest_feature($cfg, 527745, 180994, $features), '10012387123'; +}; + + +done_testing(); diff --git a/t/open311.t b/t/open311.t index 73bb488d5..9524006b8 100644 --- a/t/open311.t +++ b/t/open311.t @@ -263,6 +263,36 @@ for my $test ( }; } +for my $test ( + { + desc => 'account_id handled correctly when present', + account_id => '1c304134-ef12-c128-9212-123908123901', + }, + { + desc => 'account_id handled correctly when 0', + account_id => '0' + }, + { + desc => 'account_id handled correctly when missing', + account_id => undef + } +) { + subtest $test->{desc} => sub { + $problem->extra( undef ); + my $extra = { + url => 'http://example.com/report/1', + defined $test->{account_id} ? ( account_id => $test->{account_id} ) : () + }; + + my $results = make_service_req( $problem, $extra, $problem->category, +'<?xml version="1.0" encoding="utf-8"?><service_requests><request><service_request_id>248</service_request_id></request></service_requests>' + ); + my $c = CGI::Simple->new( $results->{req}->content ); + + is $c->param( 'account_id' ), $test->{account_id}, 'correct account_id'; + }; +} + subtest 'test always_send_email' => sub { my $email = $user->email; $user->email(undef); diff --git a/templates/email/westminster/_email_color_overrides.html b/templates/email/westminster/_email_color_overrides.html new file mode 100644 index 000000000..362f58d53 --- /dev/null +++ b/templates/email/westminster/_email_color_overrides.html @@ -0,0 +1,21 @@ +[% + +westminster_black = '#0d0e16' +westminster_blue = '#3065bd' +westminster_navy = '#0b2265' +westminster_yellow = '#fecb00' +westminster_grey = '#f7f5f6' + +body_background_color = westminster_grey +header_background_color = '#fff' +secondary_column_background_color = '#fff' + +link_text_color = westminster_blue + +header_padding = "20px" # a full CSS padding property (eg: top/right/bottom/left) + +logo_width = "139" # pixel measurement, but without 'px' suffix +logo_height = "35" # pixel measurement, but without 'px' suffix +logo_file = "site-logo@2.png" + +%] diff --git a/templates/email/westminster/_email_setting_overrides.html b/templates/email/westminster/_email_setting_overrides.html new file mode 100644 index 000000000..9dce49a83 --- /dev/null +++ b/templates/email/westminster/_email_setting_overrides.html @@ -0,0 +1,5 @@ +[% + +header_style = "padding: $header_padding; background: $header_background_color; color: $header_text_color; border-bottom: 3px solid $westminster_yellow;" + +%]
\ No newline at end of file diff --git a/templates/web/base/admin/bodies/contact-form.html b/templates/web/base/admin/bodies/contact-form.html index efc576b24..312b6ab96 100644 --- a/templates/web/base/admin/bodies/contact-form.html +++ b/templates/web/base/admin/bodies/contact-form.html @@ -142,7 +142,7 @@ as well.") %] [% END %] <input class="hidden-js js-group-item-template form-control" type="text" name="group" value="" size="30"> <p class="hidden-nojs"> - <button class="btn btn--small js-group-item-add">[% loc('Add group') %]</button> + <button type="button" class="btn btn--small js-group-item-add">[% loc('Add group') %]</button> </p> [% ELSE %] <input class="form-control" type="text" name="group" value="[% contact.extra.group | html %]" size="30"> diff --git a/templates/web/base/admin/extra-metadata-form.html b/templates/web/base/admin/extra-metadata-form.html index 59d07ddf9..aad14ea37 100644 --- a/templates/web/base/admin/extra-metadata-form.html +++ b/templates/web/base/admin/extra-metadata-form.html @@ -1,7 +1,7 @@ <ul class="js-metadata-items"> [% FOR meta IN metas.merge([{}]) %] <li class="js-metadata-item [% IF loop.last %]hidden-js js-metadata-item-template[% END %]" data-index="[% loop.index %]"> - <button class="btn btn--small js-metadata-item-remove hidden-nojs">[% loc('Remove field') %]</button> + <button type="button" class="btn btn--small js-metadata-item-remove hidden-nojs">[% loc('Remove field') %]</button> <div class="admin-hint"><p>[% loc('The ordering of this field on the report page. Fields are shown in ascending order according to this value.') %]</p></div> <label> @@ -75,17 +75,17 @@ [% loc('Name') %] <input class="js-metadata-option-name" name="metadata[[% outer_loop.index %]].values[[% loop.index %]].name" type="text" value="[% option.name | html %]"> </label> - <button class="btn btn--small js-metadata-option-remove hidden-nojs">[% loc('Remove') %]</button> + <button type="button" class="btn btn--small js-metadata-option-remove hidden-nojs">[% loc('Remove') %]</button> </li> [% END %] <li class="hidden-nojs"> - <button class="btn btn--small js-metadata-option-add">[% loc('Add option') %]</button> + <button type="button" class="btn btn--small js-metadata-option-add">[% loc('Add option') %]</button> </li> </ul> </div> </li> [%- END %] <li class="hidden-nojs"> - <button class="btn btn--small js-metadata-item-add">[% loc('Add field') %]</button> + <button type="button" class="btn btn--small js-metadata-item-add">[% loc('Add field') %]</button> </li> </ul> diff --git a/templates/web/base/around/_error_multiple.html b/templates/web/base/around/_error_multiple.html index 34164973a..337ce7c77 100644 --- a/templates/web/base/around/_error_multiple.html +++ b/templates/web/base/around/_error_multiple.html @@ -10,7 +10,10 @@ [% IF match.icon %] <img src="[% match.icon %]" alt=""> [% END %] - <a href="/around?lat=[% match.latitude | uri %]&lon=[% match.longitude | uri %][% IF c.req.params.category %]&category=[% c.req.params.category | uri %][% END %]">[% match.address | html %]</a> + <a href="/around?lat=[% match.latitude | uri %]&lon=[% match.longitude | uri ~%] + [%~ IF c.get_param('category') %]&category=[% c.get_param('category') | uri %][% END ~%] + [%~ IF c.get_param('filter_category') %]&filter_category=[% c.get_param('filter_category') | uri %][% END ~%] + [%~ IF c.get_param('filter_group') %]&filter_group=[% c.get_param('filter_group') | uri %][% END %]">[% match.address | html %]</a> </li> [% END %] </ul> diff --git a/templates/web/base/around/display_location.html b/templates/web/base/around/display_location.html index 6c71ad631..2d36161be 100755 --- a/templates/web/base/around/display_location.html +++ b/templates/web/base/around/display_location.html @@ -51,6 +51,10 @@ <input type="hidden" name="latitude" id="fixmystreet.latitude" value="[% latitude | html %]"> <input type="hidden" name="longitude" id="fixmystreet.longitude" value="[% longitude | html %]"> + + [% IF c.get_param('filter_group') %] + <input type="hidden" name="filter_group" id="filter_group" value="[% c.get_param('filter_group') | html %]"> + [% END %] [% END %] [% map_html %] diff --git a/templates/web/base/around/postcode_form.html b/templates/web/base/around/postcode_form.html index ca82f3388..0ffff711e 100644 --- a/templates/web/base/around/postcode_form.html +++ b/templates/web/base/around/postcode_form.html @@ -20,11 +20,18 @@ <input type="hidden" name="partial" value="[% partial_token.token %]"> [% END %] - [% IF c.user_exists AND c.user.categories.size %] + [% IF c.get_param('filter_category') OR c.get_param('filter_group') %] + <input type="hidden" name="filter_category" value="[% c.get_param('filter_category') | html %]"> + <input type="hidden" name="filter_group" value="[% c.get_param('filter_group') | html %]"> + [% ELSIF c.user_exists AND c.user.categories.size %] <input type="hidden" name="filter_category" value="[% c.user.categories_string | html %]"> [% END %] </form> - <a href="[% c.uri_for('/around') %]" id="geolocate_link">… [% loc('or locate me automatically') %]</a> + [%~ SET link_params = {}; + IF c.get_param('filter_category'); link_params.filter_category = c.get_param('filter_category') | uri; END; + IF c.get_param('filter_group'); link_params.filter_group = c.get_param('filter_group') | uri; END; + %] + <a href="[% c.uri_for('/around', link_params) | html %]" id="geolocate_link">… [% loc('or locate me automatically') %]</a> [% UNLESS possible_location_matches %] [% INCLUDE 'around/_postcode_form_post.html' %] diff --git a/templates/web/base/auth/general.html b/templates/web/base/auth/general.html index e2e880871..30b1bf63f 100644 --- a/templates/web/base/auth/general.html +++ b/templates/web/base/auth/general.html @@ -24,15 +24,22 @@ [% IF NOT oauth_need_email AND c.cobrand.social_auth_enabled %] [% IF c.config.FACEBOOK_APP_ID %] <div class="form-box"> - <button name="facebook_sign_in" id="facebook_sign_in" value="facebook_sign_in" class="btn btn--block btn--social btn--facebook"> + <button name="social_sign_in" id="facebook_sign_in" value="facebook" class="btn btn--block btn--social btn--facebook"> <img alt="" src="/i/facebook-icon-32.png" width="17" height="32"> [% loc('Log in with Facebook') %] </button> </div> [% END %] + [% IF c.cobrand.feature('oidc_login') %] + <div class="form-box"> + <button name="social_sign_in" id="oidc_sign_in" value="oidc" class="btn btn--block btn--social btn--oidc"> + [% tprintf(loc('Login with %s'), c.cobrand.feature('oidc_login').display_name) %] + </button> + </div> + [% END %] [% IF c.config.TWITTER_KEY %] <div class="form-box"> - <button name="twitter_sign_in" id="twitter_sign_in" value="twitter_sign_in" class="btn btn--block btn--social btn--twitter"> + <button name="social_sign_in" id="twitter_sign_in" value="twitter" class="btn btn--block btn--social btn--twitter"> <img alt="" src="/i/twitter-icon-32.png" width="17" height="32"> [% loc('Log in with Twitter') %] </button> diff --git a/templates/web/base/contact/index.html b/templates/web/base/contact/index.html index 90a2cd778..05a24160f 100644 --- a/templates/web/base/contact/index.html +++ b/templates/web/base/contact/index.html @@ -12,7 +12,11 @@ [% IF c.cobrand.moniker == 'fixmystreet' %] <h1>FixMyStreet technical support</h1> [% ELSE %] -<h1>[% loc('Contact the team') %]</h1> + [% TRY %] + [% INCLUDE 'contact/_heading.html' %] + [% CATCH file %] + <h1>[% loc('Contact the team') %]</h1> + [% END %] [% END %] <form method="post" action="/contact/submit" class="validate"> diff --git a/templates/web/base/report/_item_expandable.html b/templates/web/base/report/_item_expandable.html index 7723ed54a..ed3ce751c 100644 --- a/templates/web/base/report/_item_expandable.html +++ b/templates/web/base/report/_item_expandable.html @@ -47,7 +47,7 @@ </div> <div class="item-list__item--expandable__actions"> - <button class="btn btn--small js-toggle-expansion" data-more="[% loc('Read more') %]" data-less="[% loc('Read less') %]">[% loc('Read more') %]</button> + <button type="button" class="btn btn--small js-toggle-expansion" data-more="[% loc('Read more') %]" data-less="[% loc('Read less') %]">[% loc('Read more') %]</button> </div> </li> diff --git a/templates/web/base/report/display_tools.html b/templates/web/base/report/display_tools.html index 4ba8c8b2c..ea12ab707 100644 --- a/templates/web/base/report/display_tools.html +++ b/templates/web/base/report/display_tools.html @@ -10,7 +10,7 @@ c.cobrand.moniker == 'fixmystreet' ? 'Unsuitable?' : loc('Report abuse') %]</a></li> [% END %] - [% IF NOT problem.extra.closed_updates AND c.cobrand.moniker != 'zurich' %] + [% IF NOT c.cobrand.updates_disallowed(problem) AND c.cobrand.moniker != 'zurich' %] <li><a rel="nofollow" id="key-tool-report-updates" class="feed js-feed" href="[% c.uri_for( '/alert/subscribe', { id => problem.id } ) %]">[% loc('Get updates' ) %]</a></li> [% END %] [% IF c.cobrand.moniker == 'fixmystreet' %] diff --git a/templates/web/base/report/form/user.html b/templates/web/base/report/form/user.html index 6381d2928..25d252e58 100644 --- a/templates/web/base/report/form/user.html +++ b/templates/web/base/report/form/user.html @@ -5,21 +5,31 @@ [% loc('Next:') %] [% loc('Tell us about you') %] </h2> [% IF c.user_exists OR NOT c.cobrand.social_auth_enabled %] - <button class="btn btn--block hidden-nojs js-new-report-user-show">[% loc('Continue') %]</button> + <button type="button" class="btn btn--block hidden-nojs js-new-report-user-show">[% loc('Continue') %]</button> [% ELSE %] [% IF c.config.FACEBOOK_APP_ID %] - <button name="facebook_sign_in" id="facebook_sign_in" value="facebook_sign_in" class="btn btn--block btn--social btn--facebook"> + <button name="social_sign_in" id="facebook_sign_in" value="facebook" class="btn btn--block btn--social btn--facebook"> <img alt="" src="/i/facebook-icon-32.png" width="17" height="32"> [% loc('Log in with Facebook') %] </button> [% END %] + [% IF c.cobrand.feature('oidc_login') %] + <button name="social_sign_in" id="oidc_sign_in" value="oidc" class="btn btn--block btn--social btn--oidc"> + [% tprintf(loc('Login with %s'), c.cobrand.feature('oidc_login').display_name) %] + </button> + [% END %] [% IF c.config.TWITTER_KEY %] - <button name="twitter_sign_in" id="twitter_sign_in" value="twitter_sign_in" class="btn btn--block btn--social btn--twitter"> + <button name="social_sign_in" id="twitter_sign_in" value="twitter" class="btn btn--block btn--social btn--twitter"> <img alt="" src="/i/twitter-icon-32.png" width="17" height="32"> [% loc('Log in with Twitter') %] </button> [% END %] - <button class="btn btn--block hidden-nojs js-new-report-user-show">[% loc('Log in with email') %]</button> + <button type="button" class="btn btn--block hidden-nojs js-new-report-user-show">[% loc('Log in with email') %]</button> +[% END %] +[% IF type == 'report' AND c.cobrand.allow_anonymous_reports == 'button' %] + <small id="or">[% loc('or') %]</small> + <button name="report_anonymously" value="yes" class="btn btn--block js-new-report-submit">[% loc('Report anonymously') %]</button> + <small>[% loc('No personal details will be stored, and you will not receive updates about this report.') %]</small> [% END %] </div> <!-- /report/form/user.html --> diff --git a/templates/web/base/report/new/duplicate_suggestions.html b/templates/web/base/report/new/duplicate_suggestions.html index dd05dc9a4..991c8d5e8 100644 --- a/templates/web/base/report/new/duplicate_suggestions.html +++ b/templates/web/base/report/new/duplicate_suggestions.html @@ -3,7 +3,7 @@ version('/js/duplicates.js'), ) -%] <div id="js-duplicate-reports" class="duplicate-report-suggestions hidden"> - <button class="duplicate-report-suggestions__close js-hide-duplicate-suggestions">[% loc('Close') %]</button> + <button type="button" class="duplicate-report-suggestions__close js-hide-duplicate-suggestions">[% loc('Close') %]</button> <h2 class="form-section-heading">[% loc('Already been reported?') %]</h2> <div class="form-section-description"> [% IF c.cobrand.is_council %] @@ -14,7 +14,7 @@ </div> <ul class="item-list"></ul> - <button class="btn btn--block js-hide-duplicate-suggestions">[% loc('Continue – report a new problem') %]</button> + <button type="button" class="btn btn--block js-hide-duplicate-suggestions">[% loc('Continue – report a new problem') %]</button> </div> <div class="js-template-get-updates hidden"> <div id="alerts" class="get-updates js-alert-list"> diff --git a/templates/web/base/report/new/form_report.html b/templates/web/base/report/new/form_report.html index 910536fe8..e5facc305 100644 --- a/templates/web/base/report/new/form_report.html +++ b/templates/web/base/report/new/form_report.html @@ -11,7 +11,7 @@ [% TRY %][% PROCESS 'report/new/_form_labels.html' %][% CATCH file %][% END %] <h2 class="form-section-heading js-hide-if-private-category">[% loc( 'Public details' ) %]</h2> - <h2 class="form-section-heading form-section-heading--private js-hide-if-public-category">[% loc( 'Report details' ) %]</h2> + <h2 class="form-section-heading form-section-heading--private js-hide-if-public-category hidden-js">[% loc( 'Report details' ) %]</h2> <div class="form-section-description" id="js-councils_text"> [% IF js %] [% PROCESS 'report/new/councils_text_all.html' list_of_names = [ loc('the local council') ] %] diff --git a/templates/web/base/report/new/form_user.html b/templates/web/base/report/new/form_user.html index 2292ed78b..49ef70abd 100644 --- a/templates/web/base/report/new/form_user.html +++ b/templates/web/base/report/new/form_user.html @@ -1,7 +1,7 @@ <!-- report/new/form_user.html --> <div class="js-hide-if-invalid-category"> - [% PROCESS 'report/form/user.html' %] + [% PROCESS 'report/form/user.html' type='report' %] <div class="hidden-js js-new-report-user-shown"> <div class="hidden-nojs form-section-preview"> @@ -10,7 +10,7 @@ <strong class="js-form-section-preview" data-source="#form_title"></strong> <span class="js-form-section-preview" data-source="#form_detail"></span> </p> - <button class="btn btn--block js-new-report-user-hide">[% loc('Edit report details') %]</button> + <button type="button" class="btn btn--block js-new-report-user-hide">[% loc('Edit report details') %]</button> </div> [% IF c.user_exists %] diff --git a/templates/web/base/report/new/login_success_form.html b/templates/web/base/report/new/login_success_form.html index 5038c6beb..b9abf2023 100644 --- a/templates/web/base/report/new/login_success_form.html +++ b/templates/web/base/report/new/login_success_form.html @@ -13,6 +13,7 @@ [% ELSE %] [% PROCESS "report/form/user_loggedout.html" type='report' object=report %] [% END %] - [% PROCESS 'report/new/form_report.html' %] + [% PROCESS 'report/new/form_report.html' %] + <input class="btn btn--primary btn--block btn--final js-submit_register" type="submit" name="submit_register" value="[% loc('Submit') %]"> </div> </fieldset> diff --git a/templates/web/base/report/new/oauth_email_form.html b/templates/web/base/report/new/oauth_email_form.html index 5b4622cda..8e5b9a7ae 100644 --- a/templates/web/base/report/new/oauth_email_form.html +++ b/templates/web/base/report/new/oauth_email_form.html @@ -17,5 +17,6 @@ <input type="hidden" name="oauth_need_email" value="1"> [% PROCESS 'report/new/form_report.html' %] + <input class="btn btn--primary btn--block btn--final js-submit_register" type="submit" name="submit_register" value="[% loc('Submit') %]"> </div> </fieldset> diff --git a/templates/web/base/report/update-form-wrapper.html b/templates/web/base/report/update-form-wrapper.html index 3a75036cc..5347df3c3 100644 --- a/templates/web/base/report/update-form-wrapper.html +++ b/templates/web/base/report/update-form-wrapper.html @@ -1,6 +1,6 @@ [% UNLESS c.cobrand.updates_disallowed(problem) %] [% IF two_column_sidebar %] - <button class="btn btn--provide-update js-provide-update hidden-nojs">[% loc('Provide an update') %]</button> + <button type="button" class="btn btn--provide-update js-provide-update hidden-nojs">[% loc('Provide an update') %]</button> <div class="hidden-js"> [% END %] [% INCLUDE 'report/update-form.html' %] diff --git a/templates/web/base/report/update-form.html b/templates/web/base/report/update-form.html index afa110280..4c631d5e7 100644 --- a/templates/web/base/report/update-form.html +++ b/templates/web/base/report/update-form.html @@ -1,5 +1,5 @@ [% allow_creation = (!c.cobrand.only_authed_can_create || (c.user && c.user.from_body)) AND NOT c.cobrand.updates_disallowed(problem) %] -[% RETURN IF NOT allow_creation OR problem.extra.closed_updates %] +[% RETURN IF NOT allow_creation %] <div id="update_form"> [% IF NOT login_success AND NOT oauth_need_email %] diff --git a/templates/web/base/report/update/form_user.html b/templates/web/base/report/update/form_user.html index d9a181e26..92120271f 100644 --- a/templates/web/base/report/update/form_user.html +++ b/templates/web/base/report/update/form_user.html @@ -1,11 +1,11 @@ <!-- report/update/form_user.html --> -[% PROCESS 'report/form/user.html' %] +[% PROCESS 'report/form/user.html' type='update' %] <div class="hidden-js js-new-report-user-shown"> <div class="hidden-nojs form-section-preview"> <h2 class="form-section-heading">[% loc('Your update') %]</h2> <p class="js-form-section-preview" data-source="#form_update"></p> - <button class="btn btn--block js-new-report-user-hide">[% loc('Edit your update') %]</button> + <button type="button" class="btn btn--block js-new-report-user-hide">[% loc('Edit your update') %]</button> </div> [% IF c.user_exists %] diff --git a/templates/web/base/reports/_list-filters.html b/templates/web/base/reports/_list-filters.html index b898817fc..6acb5936c 100644 --- a/templates/web/base/reports/_list-filters.html +++ b/templates/web/base/reports/_list-filters.html @@ -47,9 +47,10 @@ [% select_category = BLOCK %] [% IF filter_categories.size %] + [% SET filter_group = c.get_param('filter_group') %] <select class="form-control js-multiple" name="filter_category" id="filter_categories" multiple data-all="[% loc('Everything') %]"> [% FOR cat IN filter_categories %] - <option value="[% cat.category | html %]"[% ' selected' IF filter_category.${cat.category} %]> + <option value="[% cat.category | html %]"[% ' selected' IF filter_category.${cat.category} OR ( filter_group AND ( cat.get_extra_metadata('group') == filter_group OR cat.category == filter_group ) ) %]> [% cat.category_display | html %] [%~ IF cat.get_extra_metadata('help_text') %] ([% cat.get_extra_metadata('help_text') %])[% END ~%] </option> diff --git a/templates/web/borsetshire/auth/_general_top.html b/templates/web/borsetshire/auth/_general_top.html index 0af3737e8..3ab3c39bc 100644 --- a/templates/web/borsetshire/auth/_general_top.html +++ b/templates/web/borsetshire/auth/_general_top.html @@ -5,16 +5,16 @@ different types of user we’ve set up on this demo site: <ul id='demo-user-list' class="clearfix"> <li> -<button class="btn" data-email="user@example.org">Normal user</button> +<button type="button" class="btn" data-email="user@example.org">Normal user</button> <span>A local resident who has created reports and updates on the site.</span> <li> -<button class="btn" data-email="cs@example.org">Customer service</button> +<button type="button" class="btn" data-email="cs@example.org">Customer service</button> <span>A customer service staff member, who can create and moderate existing reports.</span> <li> -<button class="btn" data-email="inspector@example.org">Inspector</button> +<button type="button" class="btn" data-email="inspector@example.org">Inspector</button> <span>An inspector staff member, who can inspect reports and create shortlists.</span> <li> -<button class="btn" data-email="super@example.org">Super user</button> +<button type="button" class="btn" data-email="super@example.org">Super user</button> <span>A superuser staff member, who can do all the above, plus also edit users, templates, and priorities in the Admin screen.</span> </ul> diff --git a/templates/web/bromley/report/update/form_user.html b/templates/web/bromley/report/update/form_user.html index c8df54c46..de151d91b 100644 --- a/templates/web/bromley/report/update/form_user.html +++ b/templates/web/bromley/report/update/form_user.html @@ -1,11 +1,11 @@ <!-- report/update/form_user.html --> -[% PROCESS 'report/form/user.html' %] +[% PROCESS 'report/form/user.html' type='update' %] <div class="hidden-js js-new-report-user-shown"> <div class="hidden-nojs form-section-preview"> <h2 class="form-section-heading">[% loc('Your update') %]</h2> <p class="js-form-section-preview" data-source="#form_update"></p> - <button class="btn btn--block js-new-report-user-hide">[% loc('Edit your update') %]</button> + <button type="button" class="btn btn--block js-new-report-user-hide">[% loc('Edit your update') %]</button> </div> [% IF c.user_exists %] diff --git a/templates/web/oxfordshire/around/postcode_form.html b/templates/web/oxfordshire/around/postcode_form.html index f598f1f98..c6028e7d6 100644 --- a/templates/web/oxfordshire/around/postcode_form.html +++ b/templates/web/oxfordshire/around/postcode_form.html @@ -19,7 +19,9 @@ <input type="hidden" name="partial" value="[% partial_token.token %]"> [% END %] - [% IF c.user_exists AND c.user.categories.size %] + [% IF c.get_param('filter_category') %] + <input type="hidden" name="filter_category" value="[% c.get_param('filter_category') | html %]"> + [% ELSIF c.user_exists AND c.user.categories.size %] <input type="hidden" name="filter_category" value="[% c.user.categories.join(",") | html %]"> [% END %] </form> diff --git a/templates/web/westminster/about/faq-en-gb.html b/templates/web/westminster/about/faq-en-gb.html new file mode 100644 index 000000000..900d13b19 --- /dev/null +++ b/templates/web/westminster/about/faq-en-gb.html @@ -0,0 +1,94 @@ +[% INCLUDE 'header.html', title = loc('Frequently Asked Questions'), bodyclass = 'twothirdswidthpage' %] + +[% INCLUDE 'about/_sidebar.html' %] + +<h1><a name="faq"></a>[% loc('Frequently Asked Questions') %]</h1> + +<dl> + + <dt>What is this site?</dt> + <dd>‘Report it’ enables you to report issues on the streets of Westminster such as fly tipping and + damaged pavements, to the Council. It’s the quickest and easiest way to report an issue. + </dd> + + <dt>How do I get in touch with Westminster City Council regarding ‘Report it’?</dt> + <dd>If you are experiencing any technical issues with the ‘Report it’ tool please let us know using our + <a href="/contact">contact page</a>.</dd> + + <dt>What sort of problems can I report with ‘Report it’?</dt> + <dd> + <p>We’re rolling out this new version of ‘Report it’ using the FixMyStreet platform and so at + this stage you can report the following issues using this tool:</p> + + <ul> + <li> + Fly tipping + </li> + <li> + Street cleaning + </li> + <li> + Street lights + </li> + <li> + Drains + </li> + <li> + Signs and bollards + </li> + </ul> + <p> + To report other issues , you can visit our <a href="https://www.westminster.gov.uk/report-it">website</a>. + </p> + <p> + Over the coming months, more of the Council’s services will be connected to the FixMyStreet platform, + as well as adding more functions. + </p> + </dd> + + <dt>What shouldn’t I use ‘Report it’ for?</dt> + <dd> + <p> + ‘Report it’ is not a way of getting in touch with the Council for all issues – please use this + site only for problems such as those listed above. The site and this page will be updated as more services + are added to Westminster’s FixMyStreet ‘Report it’ platform. + </p> + + <p> + Using this site for other matters may result in a delay in your report getting to the right department. + </p> + + <p> + You will need to contact the Council directly for problems not listed above. You can view more reporting + methods on the <a href="https://www.westminster.gov.uk/report-it">Westminster City Council’s + website</a>. + </p> + </dd> + + <dt>How do I use the site?</dt> + <dd>After entering a postcode or location, you’ll see a map of that area. You can view problems already + reported in that area, or report ones of your own by clicking on the map at the location of the problem.</dd> + + <dt>How are the problems solved?</dt> + <dd>They are reported to the relevant services at Westminster City Council who will then investigate and respond. + </dd> + + <dt>Is it free?</dt> + <dd>Yes, the site is free to use for logging reports.</dd> + + <dt> + Can I use ‘Report it’ on my mobile? + </dt> + <dd> + Yes, the ‘Report it’ website adapts to work on your mobile phone’s browser. + </dd> + + <dt>Who built [% site_name %]?</dt> + <dd>The FixMyStreet Platform was + built by <a href="https://www.mysociety.org/">mySociety</a>. + Westminster City Council is customising the platform for its residents and users. + </dd> + +</dl> + +[% INCLUDE 'footer.html' pagefooter = 'yes' %] diff --git a/templates/web/westminster/around/intro.html b/templates/web/westminster/around/intro.html new file mode 100644 index 000000000..a40891769 --- /dev/null +++ b/templates/web/westminster/around/intro.html @@ -0,0 +1 @@ + <h1>Report it</h1> diff --git a/templates/web/westminster/auth/general.html b/templates/web/westminster/auth/general.html new file mode 100644 index 000000000..55999584e --- /dev/null +++ b/templates/web/westminster/auth/general.html @@ -0,0 +1,89 @@ +[% INCLUDE 'header.html', bodyclass='authpage', title = loc('Sign in') %] + +<h1> + [% loc('Sign in') %] +</h1> + +[% TRY %][% INCLUDE 'auth/_general_top.html' %][% CATCH file %][% END %] + +[% IF oauth_need_email %] + <p class="form-error">[% loc('We need your email address, please give it below.') %]</p> +[% END %] +[% IF oauth_failure %] + <p class="form-error">[% loc('Sorry, we could not log you in.') %]</p> +[% END %] + +<form action="/auth" method="post" name="general_auth" class="validate"> + <fieldset> + + <input type="hidden" name="r" value="[% c.req.params.r | html %]"> + +[% IF NOT oauth_need_email AND c.cobrand.social_auth_enabled AND c.cobrand.feature('oidc_login') %] + + <div class="form-box"> + <button name="social_sign_in" id="oidc_sign_in" value="oidc" class="btn btn--block btn--social btn--oidc"> + [% tprintf(loc('Sign in with %s'), c.cobrand.feature('oidc_login').display_name) %] + </button> + </div> + +[% ELSE %] + + [% loc_username_error = INCLUDE 'auth/_username_error.html' default='email' %] + +[% IF c.config.SMS_AUTHENTICATION %] + [% SET username_label = loc('Your email or mobile') %] +[% ELSE %] + [% SET username_label = loc('Your email') %] +[% END %] + + <label class="n" for="username">[% username_label %]</label> + [% IF loc_username_error %] + <div class="form-error">[% loc_username_error %]</div> + [% END %] + <input type="text" class="form-control required" id="username" name="username" value="[% username | html %]" autocomplete="username" + autofocus> + + <div id="form_sign_in"> + [% IF oauth_need_email %] + [% INCLUDE form_sign_in_no %] + <input type="hidden" name="oauth_need_email" value="1"> + [% ELSE %] + [% INCLUDE form_sign_in_yes %] + [% INCLUDE form_sign_in_no %] + [% END %] + </div> + +[% END %] + + </fieldset> +</form> + +[% INCLUDE 'footer.html' %] + +[% BLOCK form_sign_in_yes %] + <p class="hidden-nojs js-sign-in-password-hide"> + <input class="btn btn--primary btn--block js-sign-in-password-btn" type="submit" name="sign_in_by_password" value="[% loc('Sign in with a password') %]"> + </p> + <div class="hidden-js js-sign-in-password"> + <label for="password_sign_in">[% loc('Your password') %]</label> + + <div class="form-txt-submit-box"> + <input type="password" name="password_sign_in" class="form-control" id="password_sign_in" value="" autocomplete="current-password"> + <input class="green-btn" type="submit" name="sign_in_by_password" value="[% loc('Sign in') %]"> + </div> + + <p> + <a href="/auth/forgot">[% loc('Forgotten your password?') %]</a> + </p> + </div> +[% END %] + +[% BLOCK form_sign_in_no %] + <p><input class="fake-link" type="submit" name="sign_in_by_code" value=" + [%~ IF c.config.SMS_AUTHENTICATION %] + [%~ loc('Email me a link or text me a code to sign in') %] + [%~ ELSE %] + [%~ loc('Email me a link to sign in') %] + [%~ END ~%] + "></p> +[% END %] diff --git a/templates/web/westminster/before_wrapper.html b/templates/web/westminster/before_wrapper.html new file mode 100644 index 000000000..8061bfbc3 --- /dev/null +++ b/templates/web/westminster/before_wrapper.html @@ -0,0 +1,6 @@ +[% IF c.config.BASE_URL == "https://www.fixmystreet.com" OR c.config.BASE_URL == "https://staging.fixmystreet.com" %] +<!-- Google Tag Manager (noscript) --> +<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-MVJJRB" +height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> +<!-- End Google Tag Manager (noscript) --> +[% END %] diff --git a/templates/web/westminster/contact/_heading.html b/templates/web/westminster/contact/_heading.html new file mode 100644 index 000000000..9554ed727 --- /dev/null +++ b/templates/web/westminster/contact/_heading.html @@ -0,0 +1 @@ +<h1>Contact</h1> diff --git a/templates/web/westminster/contact/blurb.html b/templates/web/westminster/contact/blurb.html new file mode 100644 index 000000000..096aaac83 --- /dev/null +++ b/templates/web/westminster/contact/blurb.html @@ -0,0 +1,11 @@ +<p> + It's often quickest to <a href="[% c.uri_for('/faq') %]">check our FAQs</a> and see if the answer is there. +</p> + +<p> + To report a problem, please <a href="/">go to the front page</a> and follow the instructions. +</p> + +<p> + The form below is to only be used to report technical issues with the FixMyStreet website. +</p> diff --git a/templates/web/westminster/footer_extra.html b/templates/web/westminster/footer_extra.html new file mode 100644 index 000000000..25f156c80 --- /dev/null +++ b/templates/web/westminster/footer_extra.html @@ -0,0 +1,35 @@ +<footer class="westminster-footer" id="westminster-footer"> + + <div class="container"> + + <div class="westminster-footer-logo"> + <a href="https://www.westminster.gov.uk/">City of Westminster</a> + </div> + + <ul class="westminster-site-links"> + <li><a href="https://www.westminster.gov.uk/apply-and-pay">Apply and pay</a></li> + <li><a href="https://www.westminster.gov.uk/report-it">Report it</a></li> + <li><a href="https://www.westminster.gov.uk/licensing-and-planning">Licensing and planning</a></li> + <li><a href="https://www.westminster.gov.uk/find-it">Find</a></li> + </ul> + + <ul class="westminster-social-links"> + <li><a href="https://www.facebook.com/CityWestminster/" class="westminster-social-links__facebook">Facebook</a></li> + <li><a href="https://twitter.com/CityWestminster" class="westminster-social-links__twitter">Twitter</a></li> + <li><a href="https://www.instagram.com/citywestminster" class="westminster-social-links__instagram">Instagram</a></li> + </ul> + + <ul class="westminster-tertiary-links"> + <li><a href="https://www.westminster.gov.uk/accessibility">Accessibility</a></li> + <li><a href="https://www.westminster.gov.uk/jobs">Careers</a></li> + <li><a href="https://www.westminster.gov.uk/data-protection">Data protection</a></li> + <li><a href="https://www.westminster.gov.uk/freedom-of-information">Freedom of information</a></li> + <li><a href="https://www.westminster.gov.uk/sites/default/files/gender_pay_gap_2018_2019.pdf" class="ext" target="_blank">Gender pay gap</a></li> + <li><a href="https://www.westminster.gov.uk/modern-slavery-statement">Modern slavery statement</a></li> + <li><a href="https://www.westminster.gov.uk/stay-informed-westminster-newsletter">Newsletters</a></li> + <li><a href="https://www.westminster.gov.uk/terms-conditions">Terms and conditions</a></li> + </ul> + + </div> + +</footer> diff --git a/templates/web/westminster/footer_extra_js.html b/templates/web/westminster/footer_extra_js.html new file mode 100644 index 000000000..ec25926c5 --- /dev/null +++ b/templates/web/westminster/footer_extra_js.html @@ -0,0 +1,10 @@ +[%~ +IF bodyclass.match('mappage'); + scripts.push( + version('/vendor/OpenLayers.Projection.OrdnanceSurvey.js'), + version('/cobrands/fixmystreet/assets.js'), + version('/cobrands/westminster/assets.js'), + ); +END +%] + diff --git a/templates/web/westminster/front/stats.html b/templates/web/westminster/front/stats.html new file mode 100644 index 000000000..d4e60f634 --- /dev/null +++ b/templates/web/westminster/front/stats.html @@ -0,0 +1,32 @@ +[% USE Number.Format %] + +[% + stats = c.cobrand.front_stats_data(); + + new_text = + stats.recency == '1 week' + ? nget( + "<big>%s</big> report in past week", + "<big>%s</big> reports in past week", + stats.new + ) + : nget( + "<big>%s</big> report recently", + "<big>%s</big> reports recently", + stats.new + ); + + updates_text = nget( + "<big>%s</big> update on reports", + "<big>%s</big> updates on reports", + stats.updates + ); + + new_n = stats.new | format_number; + updates_n = stats.updates | format_number; +%] + +<div id="front_stats"> + <div>[% tprintf( new_text, decode(new_n) ) %]</div> + <div>[% tprintf( updates_text, decode(updates_n) ) %]</div> +</div> diff --git a/templates/web/westminster/header_extra.html b/templates/web/westminster/header_extra.html new file mode 100644 index 000000000..bcae1e7e3 --- /dev/null +++ b/templates/web/westminster/header_extra.html @@ -0,0 +1,3 @@ +[% INCLUDE 'tracking_code.html' %] +<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700&display=swap" rel="stylesheet"> +<link rel="shortcut icon" type="image/x-icon" href="/cobrands/westminster/favicon.ico"> diff --git a/templates/web/westminster/header_logo.html b/templates/web/westminster/header_logo.html new file mode 100644 index 000000000..a58fea318 --- /dev/null +++ b/templates/web/westminster/header_logo.html @@ -0,0 +1,2 @@ +<a href="https://www.westminster.gov.uk" id="site-logo" title="Home">[% site_name %]</a> +<a href="[% c.cobrand.base_url IF admin %]/" id="report-cta" title="[%- loc('Report a problem') -%]">[%- loc('Report') -%]</a> diff --git a/templates/web/westminster/next_steps.html b/templates/web/westminster/next_steps.html new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/templates/web/westminster/next_steps.html diff --git a/templates/web/westminster/report/form/user.html b/templates/web/westminster/report/form/user.html new file mode 100644 index 000000000..196d2ea4d --- /dev/null +++ b/templates/web/westminster/report/form/user.html @@ -0,0 +1,35 @@ +<!-- report/form/user.html --> +<div class="js-new-report-user-hidden form-section-preview form-section-preview--next + [%~ ' hidden-nojs' IF c.user_exists OR NOT c.cobrand.social_auth_enabled %]"> + <h2 class="form-section-heading form-section-heading--private hidden-nojs"> + [% loc('Next:') %] [% loc('Tell us about you') %] + </h2> +[% IF c.user_exists OR NOT c.cobrand.social_auth_enabled %] + <button type="button" class="btn btn--block hidden-nojs js-new-report-user-show">[% loc('Continue') %]</button> +[% ELSE %] + [% IF c.config.FACEBOOK_APP_ID %] + <button name="social_sign_in" id="facebook_sign_in" value="facebook" class="btn btn--block btn--social btn--facebook"> + <img alt="" src="/i/facebook-icon-32.png" width="17" height="32"> + [% loc('Log in with Facebook') %] + </button> + [% END %] + [% IF c.cobrand.feature('oidc_login') %] + <button name="social_sign_in" id="oidc_sign_in" value="oidc" class="btn btn--block btn--social btn--oidc"> + [% tprintf(loc('Sign in with %s'), c.cobrand.feature('oidc_login').display_name) %] + </button> + <small>Receive updates and view your reports</small> + [% END %] + [% IF c.config.TWITTER_KEY %] + <button name="social_sign_in" id="twitter_sign_in" value="twitter" class="btn btn--block btn--social btn--twitter"> + <img alt="" src="/i/twitter-icon-32.png" width="17" height="32"> + [% loc('Log in with Twitter') %] + </button> + [% END %] +[% END %] +[% IF type == 'report' AND c.cobrand.allow_anonymous_reports == 'button' %] + <small id="or">[% loc('or') %]</small> + <button name="report_anonymously" value="yes" class="btn btn--block js-new-report-submit">[% loc('Submit report anonymously') %]</button> + <small>[% loc('No personal details will be stored, and you will not receive updates about this report.') %]</small> +[% END %] +</div> +<!-- /report/form/user.html --> diff --git a/templates/web/westminster/report/new/_category_extra_field_notice.html b/templates/web/westminster/report/new/_category_extra_field_notice.html new file mode 100644 index 000000000..cecabd574 --- /dev/null +++ b/templates/web/westminster/report/new/_category_extra_field_notice.html @@ -0,0 +1,3 @@ +[% IF meta.datatype_description AND NOT hide_descriptions %] + <p class="form-hint">[% meta.datatype_description %]</p> +[% END %] diff --git a/templates/web/westminster/report/new/_form_labels.html b/templates/web/westminster/report/new/_form_labels.html new file mode 100644 index 000000000..2fafc55b0 --- /dev/null +++ b/templates/web/westminster/report/new/_form_labels.html @@ -0,0 +1,4 @@ +[% +SET form_title_placeholder = 'e.g. ‘Rubbish dumped on Example St, next to post box’'; +SET form_detail_placeholder = 'e.g. ‘Six large bags of rubbish, including shoes and clothes…’'; +%]
\ No newline at end of file diff --git a/templates/web/westminster/report/new/roads_message.html b/templates/web/westminster/report/new/roads_message.html new file mode 100644 index 000000000..dbed24eed --- /dev/null +++ b/templates/web/westminster/report/new/roads_message.html @@ -0,0 +1,5 @@ +<div id="js-roads-responsibility" class="box-warning hidden"> + <div id="js-not-an-asset" class="hidden js-responsibility-message"> + <p>Please select <span class="js-roads-asset" data-original="an item">an item</span> from the map on which to make a report.</p> + </div> +</div> diff --git a/templates/web/westminster/site-name.html b/templates/web/westminster/site-name.html new file mode 100644 index 000000000..6e69a1843 --- /dev/null +++ b/templates/web/westminster/site-name.html @@ -0,0 +1 @@ +Report it diff --git a/templates/web/westminster/tracking_code.html b/templates/web/westminster/tracking_code.html new file mode 100644 index 000000000..14b65854e --- /dev/null +++ b/templates/web/westminster/tracking_code.html @@ -0,0 +1,11 @@ +[% IF c.config.BASE_URL == "https://www.fixmystreet.com" OR c.config.BASE_URL == "https://staging.fixmystreet.com" %] +<!-- Google Tag Manager --> +<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer','GTM-MVJJRB');</script> +<!-- End Google Tag Manager --> +[% ELSE %] +<!-- Tracking code not inserted as "[% c.config.BASE_URL %]" not "https://www.fixmystreet.com" --> +[% END %] diff --git a/web/cobrands/fixmystreet/assets.js b/web/cobrands/fixmystreet/assets.js index caef01886..096a5824e 100644 --- a/web/cobrands/fixmystreet/assets.js +++ b/web/cobrands/fixmystreet/assets.js @@ -27,8 +27,15 @@ OpenLayers.Layer.VectorAsset = OpenLayers.Class(OpenLayers.Layer.Vector, { relevant: function() { var category = $('select#form_category').val(), - layer = this.fixmystreet; - return OpenLayers.Util.indexOf(layer.asset_category, category) != -1 && + group = $('select#category_group').val(), + layer = this.fixmystreet, + relevant; + if (layer.asset_group) { + relevant = (layer.asset_group === group); + } else { + relevant = (OpenLayers.Util.indexOf(layer.asset_category, category) != -1); + } + return relevant && ( !layer.body || OpenLayers.Util.indexOf(fixmystreet.bodies, layer.body) != -1 ); }, @@ -77,10 +84,10 @@ OpenLayers.Layer.VectorAsset = OpenLayers.Class(OpenLayers.Layer.Vector, { }, get_select_control: function() { - var controls = fixmystreet.map.getControlsByClass('OpenLayers.Control.SelectFeature'); - for (var i=0; i<controls.length; i++) { + var controls = this.controls || []; + for (var i = 0; i < controls.length; i++) { var control = controls[i]; - if (control.layer == this && !control.hover) { + if (!control.hover) { return control; } } @@ -253,7 +260,7 @@ var fault_popup = null; function init_asset_layer(layer, pins_layer) { layer.update_layer_visibility(); fixmystreet.map.addLayer(layer); - if (layer.fixmystreet.asset_category) { + if (layer.fixmystreet.asset_category || layer.fixmystreet.asset_group) { fixmystreet.map.events.register( 'zoomend', layer, check_zoom_message_visibility); } @@ -370,7 +377,8 @@ function check_zoom_message_visibility() { if (this.fixmystreet.non_interactive) { return; } - var category = $("select#form_category").val(), + var select = this.fixmystreet.asset_group ? 'category_group' : 'form_category'; + var category = $("select#" + select).val() || '', prefix = category.replace(/[^a-z]/gi, ''), id = "category_meta_message_" + prefix, $p = $('#' + id); @@ -390,6 +398,11 @@ function check_zoom_message_visibility() { $p.html('Zoom in to pick a ' + this.fixmystreet.asset_item + ' from the map'); } + } else if (this.fixmystreet.asset_group) { + prefix = this.fixmystreet.asset_group.replace(/[^a-z]/gi, ''); + id = "category_meta_message_" + prefix; + $p = $('#' + id); + $p.remove(); } else { $.each(this.fixmystreet.asset_category, function(i, c) { var prefix = c.replace(/[^a-z]/gi, ''), @@ -407,7 +420,20 @@ function layer_visibilitychanged() { } return; } else if (!this.getVisibility()) { - this.asset_not_found(); + asset_unselected.call(this); + this.asset_not_found(); // as trigger won't call on non-visible layers + } + + var controls = this.controls || []; + var j; + if (this.getVisibility()) { + for (j = 0; j < controls.length; j++) { + controls[j].activate(); + } + } else { + for (j = 0; j < controls.length; j++) { + controls[j].deactivate(); + } } check_zoom_message_visibility.call(this); @@ -480,6 +506,231 @@ function get_fault_stylemap() { }); } +function construct_protocol_options(options) { + var protocol_options; + if (options.http_options !== undefined) { + protocol_options = options.http_options; + OpenLayers.Util.applyDefaults(options, { + format_class: OpenLayers.Format.GML, + format_options: {} + }); + if (options.geometryName) { + options.format_options.geometryName = options.geometryName; + } + protocol_options.format = new options.format_class(options.format_options); + } else { + protocol_options = { + version: "1.1.0", + url: options.wfs_url, + featureType: options.wfs_feature, + geometryName: options.geometryName + }; + if (options.srsName !== undefined) { + protocol_options.srsName = options.srsName; + } else if (fixmystreet.wmts_config) { + protocol_options.srsName = fixmystreet.wmts_config.map_projection; + } + if (options.propertyNames) { + protocol_options.propertyNames = options.propertyNames; + } + } + return protocol_options; +} + +function construct_protocol_class(options) { + if (options.http_options !== undefined) { + return options.protocol_class || OpenLayers.Protocol.HTTP; + } else { + return OpenLayers.Protocol.WFS; + } +} + +function construct_layer_options(options, protocol) { + var StrategyClass = options.strategy_class || OpenLayers.Strategy.BBOX; + + var max_resolution = options.max_resolution; + if (typeof max_resolution === 'object') { + max_resolution = max_resolution[fixmystreet.cobrand]; + } + + var layer_options = { + fixmystreet: options, + strategies: [new StrategyClass()], + protocol: protocol, + visibility: false, + maxResolution: max_resolution, + minResolution: options.min_resolution, + styleMap: options.stylemap || get_asset_stylemap(), + assets: true + }; + if (options.attribution !== undefined) { + layer_options.attribution = options.attribution; + } + if (options.srsName !== undefined) { + layer_options.projection = new OpenLayers.Projection(options.srsName); + } else if (fixmystreet.wmts_config) { + layer_options.projection = new OpenLayers.Projection(fixmystreet.wmts_config.map_projection); + } + + if (options.filter_key) { + // Add this filter to the layer, so it can potentially be used + // in the request (though only Bristol currently does this). + if (OpenLayers.Util.isArray(options.filter_value)) { + layer_options.filter = new OpenLayers.Filter.Logical({ + type: OpenLayers.Filter.Logical.OR, + filters: $.map(options.filter_value, function(value) { + return new OpenLayers.Filter.Comparison({ + type: OpenLayers.Filter.Comparison.EQUAL_TO, + property: options.filter_key, + value: value + }); + }) + }); + } else if (typeof options.filter_value === 'function') { + layer_options.filter = new OpenLayers.Filter.FeatureId({ + type: OpenLayers.Filter.Function, + evaluate: options.filter_value + }); + } else { + layer_options.filter = new OpenLayers.Filter.Comparison({ + type: OpenLayers.Filter.Comparison.EQUAL_TO, + property: options.filter_key, + value: options.filter_value + }); + } + // Add a strategy filter to the layer, to filter the incoming results + // after they are received. Bristol does not need this, but has to ask + // for the filter data in its response so it doesn't then disappear. + layer_options.strategies.push(new OpenLayers.Strategy.Filter({filter: layer_options.filter})); + } + + return layer_options; +} + +function construct_layer_class(options) { + var layer_class = options.class || OpenLayers.Layer.VectorAsset; + if (options.usrn || options.road) { + layer_class = OpenLayers.Layer.VectorNearest; + } + return layer_class; +} + +function construct_fault_layer(options, protocol_options, layer_options) { + if (!options.wfs_fault_feature) { + return null; + } + + // A non-interactive layer to display existing asset faults + var po = { + featureType: options.wfs_fault_feature + }; + OpenLayers.Util.applyDefaults(po, protocol_options); + var fault_protocol = new OpenLayers.Protocol.WFS(po); + var lo = { + strategies: [new OpenLayers.Strategy.BBOX()], + protocol: fault_protocol, + styleMap: get_fault_stylemap(), + assets: true + }; + OpenLayers.Util.applyDefaults(lo, layer_options); + asset_fault_layer = new OpenLayers.Layer.Vector("WFS", lo); + asset_fault_layer.events.register( 'loadstart', null, fixmystreet.maps.loading_spinner.show); + asset_fault_layer.events.register( 'loadend', null, fixmystreet.maps.loading_spinner.hide); + return asset_fault_layer; +} + +function construct_asset_layer(options) { + // An interactive layer for selecting an asset (e.g. street light) + var protocol_options = construct_protocol_options(options); + var protocol_class = construct_protocol_class(options); + var protocol = new protocol_class(protocol_options); + + var layer_options = construct_layer_options(options, protocol); + var layer_class = construct_layer_class(options); + var asset_layer = new layer_class(options.name || "WFS", layer_options); + + var asset_fault_layer = construct_fault_layer(options, protocol_options, layer_options); + if (asset_fault_layer) { + asset_layer.fixmystreet.fault_layer = asset_fault_layer; + } + + return asset_layer; +} + +function construct_select_layer_events(asset_layer, options) { + asset_layer.events.register( 'featureselected', asset_layer, asset_selected); + asset_layer.events.register( 'featureunselected', asset_layer, asset_unselected); + + // When panning/zooming the map check that this layer is still correctly shown + // and any selected marker is preserved + asset_layer.events.register( 'loadend', asset_layer, layer_loadend); + + if (options.disable_pin_snapping) { + // The pin is snapped to the centre of a feature by the select + // handler. We can stop this handler from running, and the pin + // being snapped, by returning false from a beforefeatureselected + // event handler. This handler does need to make sure the + // attributes of the clicked feature are applied to the extra + // details form fields first though. + asset_layer.events.register( 'beforefeatureselected', asset_layer, function(e) { + var attributes = this.fixmystreet.attributes; + if (attributes) { + set_fields_from_attributes(attributes, e.feature); + } + + // The next click on the map may not be on an asset - so + // clear the fields for this layer when the pin is next + // updated. If it is on an asset then the fields will be + // set by whatever feature was selected. + $(fixmystreet).one('maps:update_pin', function() { + if (attributes) { + clear_fields_for_attributes(attributes); + } + }); + return false; + }); + } +} + +// Set up handler for selecting/unselecting markers +function construct_select_feature_control(asset_layers, options) { + if (options.non_interactive) { + return; + } + + $.each(asset_layers, function(i, layer) { + construct_select_layer_events(layer, options); + }); + + return new OpenLayers.Control.SelectFeature(asset_layers); +} + +function construct_hover_feature_control(asset_layers, options) { + // Even if an asset layer is marked as non-interactive it can still have + // a hover style which we'll need to set up. + if (options.non_interactive && !(options.stylemap && options.stylemap.styles.hover)) { + return; + } + + // Set up handlers for simply hovering over an asset marker + var hover_feature_control = new OpenLayers.Control.SelectFeature( + asset_layers, + { + hover: true, + highlightOnly: true, + renderIntent: 'hover' + } + ); + hover_feature_control.events.register('beforefeaturehighlighted', null, function(e) { + // Don't let marker go from selected->hover state, + // as it causes some mad flickering effect. + if (e.feature.renderIntent == 'select') { + return false; + } + }); + return hover_feature_control; +} + // fixmystreet.pin_prefix isn't always available here, due // to file loading order, so get it from the DOM directly. var map_data = document.getElementById('js-map-data'); @@ -531,186 +782,18 @@ fixmystreet.assets = { } options = $.extend(true, {}, default_options, options); + var asset_layer = this.add_layer(options); + this.add_controls([asset_layer], options); + }, - var asset_fault_layer = null; - - // An interactive layer for selecting an asset (e.g. street light) - var protocol_options; - var protocol; - if (options.http_options !== undefined) { - protocol_options = options.http_options; - OpenLayers.Util.applyDefaults(options, { - format_class: OpenLayers.Format.GML, - format_options: {} - }); - if (options.geometryName) { - options.format_options.geometryName = options.geometryName; - } - protocol_options.format = new options.format_class(options.format_options); - var protocol_class = options.protocol_class || OpenLayers.Protocol.HTTP; - protocol = new protocol_class(protocol_options); - } else { - protocol_options = { - version: "1.1.0", - url: options.wfs_url, - featureType: options.wfs_feature, - geometryName: options.geometryName - }; - if (options.srsName !== undefined) { - protocol_options.srsName = options.srsName; - } else if (fixmystreet.wmts_config) { - protocol_options.srsName = fixmystreet.wmts_config.map_projection; - } - if (options.propertyNames) { - protocol_options.propertyNames = options.propertyNames; - } - protocol = new OpenLayers.Protocol.WFS(protocol_options); - } - var StrategyClass = options.strategy_class || OpenLayers.Strategy.BBOX; - + add_layer: function(options) { // Upgrade `asset_category` to an array, in the case that this layer is // only associated with a single category. if (options.asset_category && !OpenLayers.Util.isArray(options.asset_category)) { options.asset_category = [ options.asset_category ]; } - var max_resolution = options.max_resolution; - if (typeof max_resolution === 'object') { - max_resolution = max_resolution[fixmystreet.cobrand]; - } - - var layer_options = { - fixmystreet: options, - strategies: [new StrategyClass()], - protocol: protocol, - visibility: false, - maxResolution: max_resolution, - minResolution: options.min_resolution, - styleMap: options.stylemap || get_asset_stylemap(), - assets: true - }; - if (options.attribution !== undefined) { - layer_options.attribution = options.attribution; - } - if (options.srsName !== undefined) { - layer_options.projection = new OpenLayers.Projection(options.srsName); - } else if (fixmystreet.wmts_config) { - layer_options.projection = new OpenLayers.Projection(fixmystreet.wmts_config.map_projection); - } - if (options.filter_key) { - // Add this filter to the layer, so it can potentially be used - // in the request (though only Bristol currently does this). - if (OpenLayers.Util.isArray(options.filter_value)) { - layer_options.filter = new OpenLayers.Filter.Logical({ - type: OpenLayers.Filter.Logical.OR, - filters: $.map(options.filter_value, function(value) { - return new OpenLayers.Filter.Comparison({ - type: OpenLayers.Filter.Comparison.EQUAL_TO, - property: options.filter_key, - value: value - }); - }) - }); - } else if (typeof options.filter_value === 'function') { - layer_options.filter = new OpenLayers.Filter.FeatureId({ - type: OpenLayers.Filter.Function, - evaluate: options.filter_value - }); - } else { - layer_options.filter = new OpenLayers.Filter.Comparison({ - type: OpenLayers.Filter.Comparison.EQUAL_TO, - property: options.filter_key, - value: options.filter_value - }); - } - // Add a strategy filter to the layer, to filter the incoming results - // after they are received. Bristol does not need this, but has to ask - // for the filter data in its response so it doesn't then disappear. - layer_options.strategies.push(new OpenLayers.Strategy.Filter({filter: layer_options.filter})); - } - - var layer_class = options.class || OpenLayers.Layer.VectorAsset; - if (options.usrn || options.road) { - layer_class = OpenLayers.Layer.VectorNearest; - } - var asset_layer = new layer_class(options.name || "WFS", layer_options); - - // A non-interactive layer to display existing asset faults - if (options.wfs_fault_feature) { - var po = { - featureType: options.wfs_fault_feature - }; - OpenLayers.Util.applyDefaults(po, protocol_options); - var fault_protocol = new OpenLayers.Protocol.WFS(po); - var lo = { - strategies: [new OpenLayers.Strategy.BBOX()], - protocol: fault_protocol, - styleMap: get_fault_stylemap(), - assets: true - }; - OpenLayers.Util.applyDefaults(lo, layer_options); - asset_fault_layer = new OpenLayers.Layer.Vector("WFS", lo); - asset_fault_layer.events.register( 'loadstart', null, fixmystreet.maps.loading_spinner.show); - asset_fault_layer.events.register( 'loadend', null, fixmystreet.maps.loading_spinner.hide); - asset_layer.fixmystreet.fault_layer = asset_fault_layer; - } - - var hover_feature_control, select_feature_control; - if (!options.non_interactive) { - // Set up handlers for selecting/unselecting markers - select_feature_control = new OpenLayers.Control.SelectFeature( asset_layer ); - asset_layer.events.register( 'featureselected', asset_layer, asset_selected); - asset_layer.events.register( 'featureunselected', asset_layer, asset_unselected); - if (options.disable_pin_snapping) { - // The pin is snapped to the centre of a feature by the select - // handler. We can stop this handler from running, and the pin - // being snapped, by returning false from a beforefeatureselected - // event handler. This handler does need to make sure the - // attributes of the clicked feature are applied to the extra - // details form fields first though. - asset_layer.events.register( 'beforefeatureselected', asset_layer, function(e) { - var attributes = this.fixmystreet.attributes; - if (attributes) { - set_fields_from_attributes(attributes, e.feature); - } - - // The next click on the map may not be on an asset - so - // clear the fields for this layer when the pin is next - // updated. If it is on an asset then the fields will be - // set by whatever feature was selected. - $(fixmystreet).one('maps:update_pin', function() { - if (attributes) { - clear_fields_for_attributes(attributes); - } - }); - return false; - }); - } - // When panning/zooming the map check that this layer is still correctly shown - // and any selected marker is preserved - asset_layer.events.register( 'loadend', asset_layer, layer_loadend); - } - - // Even if an asset layer is marked as non-interactive it can still have - // a hover style which we'll need to set up. - if (!options.non_interactive || (options.stylemap && options.stylemap.styles.hover)) { - // Set up handlers for simply hovering over an asset marker - hover_feature_control = new OpenLayers.Control.SelectFeature( - asset_layer, - { - hover: true, - highlightOnly: true, - renderIntent: 'hover' - } - ); - hover_feature_control.events.register('beforefeaturehighlighted', null, function(e) { - // Don't let marker go from selected->hover state, - // as it causes some mad flickering effect. - if (e.feature.renderIntent == 'select') { - return false; - } - }); - } + var asset_layer = construct_asset_layer(options); if (!options.always_visible || options.road) { asset_layer.events.register( 'visibilitychanged', asset_layer, layer_visibilitychanged); @@ -724,12 +807,22 @@ fixmystreet.assets = { if (options.always_visible) { asset_layer.setVisibility(true); } - if (hover_feature_control) { - fixmystreet.assets.controls.push(hover_feature_control); - } - if (select_feature_control) { - fixmystreet.assets.controls.push(select_feature_control); - } + return asset_layer; + }, + + add_controls: function(asset_layers, options) { + var select_feature_control = construct_select_feature_control(asset_layers, options); + var hover_feature_control = construct_hover_feature_control(asset_layers, options); + + $.each(asset_layers, function(i, asset_layer) { + asset_layer.controls = asset_layer.controls || []; + if (hover_feature_control) { + asset_layer.controls.push(hover_feature_control); + } + if (select_feature_control) { + asset_layer.controls.push(select_feature_control); + } + }); }, init: function() { @@ -759,12 +852,12 @@ fixmystreet.assets = { var pins_layer = fixmystreet.map.getLayersByName("Pins")[0]; for (var i = 0; i < fixmystreet.assets.layers.length; i++) { - init_asset_layer(fixmystreet.assets.layers[i], pins_layer); - } - - for (i = 0; i < fixmystreet.assets.controls.length; i++) { - fixmystreet.map.addControl(fixmystreet.assets.controls[i]); - fixmystreet.assets.controls[i].activate(); + var asset_layer = fixmystreet.assets.layers[i]; + var controls = asset_layer.controls || []; + for (var j = 0; j < controls.length; j++) { + fixmystreet.map.addControl(controls[j]); + } + init_asset_layer(asset_layer, pins_layer); } } }; diff --git a/web/cobrands/fixmystreet/fixmystreet.js b/web/cobrands/fixmystreet/fixmystreet.js index 7aac072ea..704a665ff 100644 --- a/web/cobrands/fixmystreet/fixmystreet.js +++ b/web/cobrands/fixmystreet/fixmystreet.js @@ -347,7 +347,7 @@ $.extend(fixmystreet.set_up, { $('.js-form-name').addClass('required'); } ); - $('#facebook_sign_in, #twitter_sign_in').click(function(e){ + $('#facebook_sign_in, #twitter_sign_in, #oidc_sign_in').click(function(e){ $('#username, #form_username_register, #form_username_sign_in').removeClass('required'); }); @@ -1242,7 +1242,7 @@ fixmystreet.fetch_reporting_data = function() { return; } $('#side-form').show(); - var old_category_group = $('#category_group').val(), + var old_category_group = $('#category_group').val() || $('#filter_group').val(), old_category = $("#form_category").val(), filter_category = $("#filter_categories").val(); diff --git a/web/cobrands/sass/_base.scss b/web/cobrands/sass/_base.scss index 8c1445b36..32a85fa04 100644 --- a/web/cobrands/sass/_base.scss +++ b/web/cobrands/sass/_base.scss @@ -379,6 +379,14 @@ select.form-control { margin: 0 0 0.5em 0; } } + + small { + margin: 0.5em auto; + display: block; + text-align: center; + font-style: normal; + width: 75%; + } } // Purposefully *not* wrapped in `html.js` because the margin-top helps @@ -387,6 +395,29 @@ select.form-control { margin-top: 1.5em; } +/* On a reporting page MyW/anon */ +small#or { + margin: 1em auto; + width: 100%; +} +small#or:before, small#or:after { + background-color: #fff; + content: ""; + display: inline-block; + height: 2px; + position: relative; + vertical-align: middle; + width: 50%; +} +small#or:before { + right: 1em; + margin-left: -50%; +} +small#or:after { + left: 1em; + margin-right: -50%; +} + // grey background, full width box .form-box { margin: 0 -1em 0.25em; diff --git a/web/cobrands/westminster/_colours.scss b/web/cobrands/westminster/_colours.scss new file mode 100644 index 000000000..be1779daa --- /dev/null +++ b/web/cobrands/westminster/_colours.scss @@ -0,0 +1,38 @@ +/* COLOURS */ + +$westminster_black: #0d0e16; +$westminster_blue: #1e6dc7; +$westminster_green: #137c71; +$westminster_pink: #c8007b; +$westminster_navy: #0b2265; +$westminster_yellow: #fecb00; +$westminster_grey: #f7f5f6; + +$primary: $westminster_yellow; + +$primary_b: #000000; +$primary_text: #222222; + +$base_bg: $westminster_grey; +$base_fg: #000; + +$nav_background_colour: #fff; +$nav_colour: $westminster_navy; +$nav_hover_background_colour: $westminster_blue; + +// Colour used for front page 'how to report a problem' steps +$col_big_numbers: #ccc; + +$header-top-border: false; + +$menu-image: 'menu-black'; + +$col_click_map: $westminster_yellow; + +$body-font: 'Open Sans', sans-serif; +$heading-font: $body-font; +$meta-font: $body-font; + +$mappage-header-height: 4.5em; + +$high-dpi-screen: '-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi';
\ No newline at end of file diff --git a/web/cobrands/westminster/assets.js b/web/cobrands/westminster/assets.js new file mode 100644 index 000000000..1e46ae192 --- /dev/null +++ b/web/cobrands/westminster/assets.js @@ -0,0 +1,313 @@ +(function(){ + +if (!fixmystreet.maps) { + return; +} + +/* First let us set up some necessary subclasses */ + +/* ArcGIS wants to receive the bounding box as a 'geometry' parameter, not 'bbox' */ +var format = new OpenLayers.Format.QueryStringFilter(); +OpenLayers.Protocol.Westminster = OpenLayers.Class(OpenLayers.Protocol.HTTP, { + filterToParams: function(filter, params) { + params = format.write(filter, params); + params.geometry = params.bbox; + delete params.bbox; + return params; + }, + CLASS_NAME: "OpenLayers.Protocol.Westminster" +}); + +/* This layer is relevant depending upon the category *and* the choice of the 'type' Open311 extra attribute question */ +var SubcatMixin = OpenLayers.Class({ + relevant: function() { + var relevant = OpenLayers.Layer.VectorAsset.prototype.relevant.apply(this, arguments), + subcategories = this.fixmystreet.subcategories, + subcategory = $(this.fixmystreet.subcategory_id).val(), + relevant_sub = OpenLayers.Util.indexOf(subcategories, subcategory) > -1; + return relevant && relevant_sub; + }, + CLASS_NAME: 'SubcatMixin' +}); +OpenLayers.Layer.VectorAssetWestminsterSubcat = OpenLayers.Class(OpenLayers.Layer.VectorAsset, SubcatMixin, { + CLASS_NAME: 'OpenLayers.Layer.VectorAssetWestminsterSubcat' +}); + +// This is required so that the found/not found actions are fired on category +// select and pin move rather than just on asset select/not select. +function uprn_init(name, options) { + OpenLayers.Layer.VectorAsset.prototype.initialize.apply(this, arguments); + $(fixmystreet).on('report_new:category_change', this.checkSelected.bind(this)); +} +OpenLayers.Layer.VectorAssetWestminsterUPRN = OpenLayers.Class(OpenLayers.Layer.VectorAsset, { + initialize: function(name, options) { uprn_init.apply(this, arguments); }, + CLASS_NAME: 'OpenLayers.Layer.VectorAssetWestminsterUPRN' +}); +OpenLayers.Layer.VectorAssetWestminsterSubcatUPRN = OpenLayers.Class(OpenLayers.Layer.VectorAsset, SubcatMixin, { + cls: OpenLayers.Layer.VectorAsset, + initialize: function(name, options) { uprn_init.apply(this, arguments); }, + CLASS_NAME: 'OpenLayers.Layer.VectorAssetWestminsterSubcatUPRN' +}); + +var url_base = 'https://tilma.staging.mysociety.org/resource-proxy/proxy.php?https://westminster.assets/'; + +var defaults = { + http_options: { + params: { + inSR: '4326', + f: 'geojson' + } + }, + asset_type: 'spot', + max_resolution: 4.777314267158508, + min_resolution: 0.5971642833948135, + asset_id_field: 'central_asset_id', + srsName: "EPSG:4326", + body: "Westminster City Council", + format_class: OpenLayers.Format.GeoJSON, + format_options: {ignoreExtraDims: true}, + protocol_class: OpenLayers.Protocol.Westminster, + strategy_class: OpenLayers.Strategy.FixMyStreet +}; + +fixmystreet.assets.add(defaults, { + http_options: { + url: url_base + '40/query?', + params: { + outFields: 'USRN' + } + }, + all_categories: true, + always_visible: true, + non_interactive: true, + stylemap: fixmystreet.assets.stylemap_invisible, + nearest_radius: 100, + usrn: { + attribute: 'USRN', + field: 'USRN' + } +}); + +var tfl_categories = [ 'Pavement damage', 'Pothole', 'Road pavement damage', 'Road or pavement damage' ]; + +fixmystreet.assets.add(defaults, { + http_options: { + url: url_base + '2/query?' + }, + asset_category: tfl_categories, + non_interactive: true, + road: true, + nearest_radius: 25, + stylemap: fixmystreet.assets.stylemap_invisible, + actions: { + found: function(layer, feature) { + if (!fixmystreet.assets.selectedFeature()) { + fixmystreet.body_overrides.only_send('TfL'); + } else { + fixmystreet.body_overrides.remove_only_send(); + } + }, + not_found: function(layer) { + fixmystreet.body_overrides.remove_only_send(); + } + } +}); + +fixmystreet.message_controller.register_category({ + body: defaults.body, + category: function() { + var category = $('#form_category').val(); + if (OpenLayers.Util.indexOf(tfl_categories, category) === -1 || + fixmystreet.body_overrides.get_only_send() === 'TfL') { + return false; + } + return true; + }, + message: 'Due to the need to consider a priority response we would ask you to call <a href="tel:+442076412000">020 7641 2000</a> with exact details of the problem.' +}); + +var layer_data = [ + { category: [ 'Food safety/hygiene' ] }, + { category: 'Damaged, dirty, or missing bin', subcategories: [ '1', '4' ], subcategory_id: '#form_bin_type' }, + { category: 'Noise', subcategories: [ '1', '3', '4', '7', '8', '9', '10' ] }, + { category: 'Smoke and odours' }, +]; + +function uprn_sort(a, b) { + a = a.attributes.ADDRESS; + b = b.attributes.ADDRESS; + var a_flat = a.match(/^(Flat|Unit)s? (\d+)/); + var b_flat = b.match(/^(Flat|Unit)s? (\d+)/); + if (a_flat && b_flat && a_flat[1] === b_flat[1]) { + return a_flat[2] - b_flat[2]; + } + return a.localeCompare(b); +} + +var old_uprn; + +function add_to_uprn_select($select, assets) { + assets.sort(uprn_sort); + $.each(assets, function(i, f) { + $select.append('<option value="' + f.attributes.UPRN + '">' + f.attributes.ADDRESS + '</option>'); + }); + if (old_uprn && $select.find('option[value=\"' + old_uprn + '\"]').length) { + $select.val(old_uprn); + } +} + +function construct_uprn_select(assets, has_children) { + old_uprn = $('#uprn').val(); + $("#uprn_select").remove(); + $('.category_meta_message').html(''); + var $div = $('<div class="extra-category-questions" id="uprn_select">'); + if (assets.length > 1 || has_children) { + $div.append('<label for="uprn">Please choose a property:</label>'); + var $select = $('<select id="uprn" class="form-control" name="UPRN" required>'); + $select.append('<option value="">---</option>'); + add_to_uprn_select($select, assets); + $div.append($select); + } else { + $div.html('You have selected <b>' + assets[0].attributes.ADDRESS + '</b>'); + } + $div.appendTo('#js-post-category-messages'); +} + +$.each(layer_data, function(i, o) { + var params = { + class: OpenLayers.Layer.VectorAssetWestminsterUPRN, + asset_category: o.category, + asset_item: 'property', + http_options: { + url: url_base + '25/query?', + params: { + where: "PARENTUPRN='XXXX' AND PROPERTYTYPE NOT IN ('Pay Phone','Street Record')", + outFields: 'UPRN,Address,ParentChild' + } + }, + max_resolution: 0.5971642833948135, + select_action: true, + attributes: { + 'UPRN': 'UPRN' + }, + actions: { + asset_found: function(asset) { + if (fixmystreet.message_controller.asset_found()) { + return; + } + var lonlat = asset.geometry.getBounds().getCenterLonLat(); + var overlap_threshold = 1; // Features considered overlapping if within 1m of each other + var overlapping_features = this.getFeaturesWithinDistance( + new OpenLayers.Geometry.Point(lonlat.lon, lonlat.lat), + overlap_threshold + ); + + var parent_uprns = []; + $.each(overlapping_features, function(i, f) { + if (f.attributes.PARENTCHILD === 'Parent') { + parent_uprns.push("PARENTUPRN='" + f.attributes.UPRN + "'"); + } + }); + parent_uprns = parent_uprns.join(' OR '); + + if (parent_uprns) { + var url = url_base + '25/query?' + OpenLayers.Util.getParameterString({ + inSR: 4326, + f: 'geojson', + outFields: 'UPRN,Address', + where: parent_uprns + }); + $.getJSON(url, function(data) { + var features = []; + $.each(data.features, function(i, f) { + features.push({ attributes: f.properties }); + }); + add_to_uprn_select($('#uprn'), features); + }); + } + construct_uprn_select(overlapping_features, parent_uprns); + }, + asset_not_found: function() { + $('.category_meta_message').html('You can pick a <b class="asset-spot">' + this.fixmystreet.asset_item + '</b> from the map »'); + $("#uprn_select").remove(); + fixmystreet.message_controller.asset_not_found(this); + } + } + }; + + if (o.subcategories) { + params.class = OpenLayers.Layer.VectorAssetWestminsterSubcatUPRN; + params.subcategories = o.subcategories; + params.subcategory_id = o.subcategory_id || '#form_type'; + } + + fixmystreet.assets.add(defaults, params); +}); + +layer_data = [ + { group: 'Street lights', item: 'street light', layers: [ 18, 50, 60 ] }, + { category: 'Pavement damage', layers: [ 14 ], road: true }, + { category: 'Pothole', layers: [ 11, 44 ], road: true }, + { group: 'Drains', item: 'gully', layers: [ 16 ] }, + + { category: 'Signs and bollards', subcategories: [ '1' ], subcategory_id: '#form_featuretypecode', item: 'bollard', layers: [ 42, 52 ] }, + { category: 'Signs and bollards', subcategories: [ 'PLFP' ], subcategory_id: '#form_featuretypecode', item: 'feeder pillar', layers: [ 56 ] }, + { category: 'Signs and bollards', subcategories: [ '3' ], subcategory_id: '#form_featuretypecode', item: 'sign', layers: [ 48, 58, 54 ] }, + { category: 'Signs and bollards', subcategories: [ '2' ], subcategory_id: '#form_featuretypecode', item: 'street nameplate', layers: [ 46 ] } +]; + +$.each(layer_data, function(i, o) { + var layers_added = []; + var attr = 'central_asset_id'; + var params = $.extend(true, {}, defaults, { + asset_category: o.category, + asset_item: o.item, + http_options: { + params: { + outFields: attr + } + }, + attributes: {} + }); + + if (o.group) { + params.asset_group = o.group; + } else if (o.subcategories) { + params.class = OpenLayers.Layer.VectorAssetWestminsterSubcat; + params.subcategories = o.subcategories; + params.subcategory_id = o.subcategory_id || '#form_type'; + } + + if (o.road) { + params.non_interactive = true; + params.nearest_radius = 100; + params.stylemap = fixmystreet.assets.stylemap_invisible; + params.usrn = { + attribute: attr, + field: attr + }; + } else { + params.attributes[attr] = attr; + } + + $.each(o.layers, function(i, l) { + var layer_url = { http_options: { url: url_base + l + '/query?' } }; + var options = $.extend(true, {}, params, layer_url); + layers_added.push(fixmystreet.assets.add_layer(options)); + }); + fixmystreet.assets.add_controls(layers_added, params); +}); + +$(function(){ + $("#problem_form").on("change.category", "#form_type, #form_featuretypecode, #form_bin_type", function() { + $(fixmystreet).trigger('report_new:category_change'); + }); +}); + +fixmystreet.message_controller.register_category({ + body: defaults.body, + category: 'Burst water main', + message: 'To report a burst water main, please <a href="https://www.thameswater.co.uk/help-and-advice/Report-a-problem/Report-a-problem">contact Thames Water</a>' +}); + +})(); diff --git a/web/cobrands/westminster/base.scss b/web/cobrands/westminster/base.scss new file mode 100644 index 000000000..3820c8fea --- /dev/null +++ b/web/cobrands/westminster/base.scss @@ -0,0 +1,232 @@ +@import "../sass/h5bp"; +@import "colours"; +@import "../sass/mixins"; + +@import "../sass/base"; + +body.frontpage { + background-color: #fff; // instead of $westminster_grey +} + +#site-logo { + background-position: 0 0; + background-size: 120px 30px; + height: 30px; + width: 130px; // 10px of white space to the right, so centred logo position matches main site + background-image: url(/cobrands/westminster/images/wcc_logo.png); + margin: 0 auto; // centre logo +} + +#site-header { + padding-top: 13px; + padding-bottom: 15px; + border-bottom: 3px solid $westminster-yellow; +} + +#nav-link { + right: auto; + left: 0.25em; // roughly match "menu" icon location on left of the main site +} + +#report-cta { + right: 1em; // right-aligned, rather than left-aligned +} + +#front-main { + background-color: $westminster_grey; + margin: 0; + padding: 1em; + text-align: inherit; + + h1 { + font-weight: bold; + color: $westminster_navy; + } + + #postcodeForm { + margin: 0; + padding: 0; + background: transparent; + color: inherit; + + div { + display: block; + border: none; + background: transparent; + position: relative; + + input#pc { + display: block; + width: 100%; + box-sizing: border-box; + padding: 10px 22px; + box-shadow: 1px 1px 5px 1px rgba(104, 104, 104, 0.4); + background: #fff; + } + + input#sub { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: block; + width: 0; + height: auto; + padding-left: 50px; + overflow: hidden; + background: transparent url(/cobrands/westminster/images/search-icon.png) no-repeat 50% 50%; + background-size: 25px 25px; + + &:hover { + background: transparent url(/cobrands/westminster/images/search-icon.png) no-repeat 50% 50%; + background-size: 25px 25px; + } + + &:focus { + outline: 1px dotted #000; + } + } + } + } + + a#geolocate_link { + background: transparent; + display: block; + padding: 0; + margin-top: 0.5em; + font-family: inherit; + font-size: 1em; + border-radius: 0; + color: $westminster_blue; + + &:hover { + background: transparent; + text-decoration: underline; + } + } + + .form-hint { + color: inherit; + } +} + +#front-howto h2, +#front-recently h2 { + font-weight: bold; + color: $westminster-navy; +} + +ol.big-numbers>li:before { + color: $westminster-navy; +} + +#front_stats { + background-color: $westminster-blue; + color: #fff; +} + +#report-cta { + border: 0; + background-color: $westminster-yellow; + &:hover, + &:focus { + color: #fff; + } +} + +.westminster-footer { + background-color: $westminster_navy; + border-top: 3px solid $westminster-yellow; + padding: 1.5em 0; + text-align: center; + + a { + color: #fff; + } +} + +.westminster-footer-logo { + a { + $image-width: 200px*(130/200); + $image-height: 49px*(130/200); + display: inline-block; + overflow: hidden; + width: $image-width; + padding-top: $image-height; + height: 0; + background-size: $image-width $image-height; + background-position: center center; + background-repeat: no-repeat; + @include svg-background-image('/cobrands/westminster/images/footer-logo'); + } +} + +.westminster-site-links, +.westminster-social-links, +.westminster-tertiary-links { + list-style: none; + margin: 0; + + li { + list-style: none; + } +} + +.westminster-site-links { + margin-top: 1.5em; + font-size: 1.1em; + + li { + margin-bottom: 1em; + } +} + +.westminster-social-links { + margin: 1.5em -6px; + + li { + display: inline-block; + margin: 0 6px; + } + + a { + display: inline-block; + overflow: hidden; + width: 38px; + padding-top: 38px; + height: 0; + background-color: $westminster_grey; + background-size: 24px 24px; + background-repeat: no-repeat; + background-position: center center; + border-radius: 38px; + } +} + +.westminster-social-links__twitter { + @include svg-background-image('/cobrands/westminster/images/footer-twitter'); +} + +.westminster-social-links__instagram { + @include svg-background-image('/cobrands/westminster/images/footer-instagram'); +} + +.westminster-social-links__facebook { + @include svg-background-image('/cobrands/westminster/images/footer-facebook'); +} + +.westminster-tertiary-links { + font-size: 0.8em; + padding-top: 2em; + border-top: 1px solid $westminster_grey; + margin: 0 -6px; + + li { + display: inline-block; + margin: 0.2em 6px; + } +} + +.item-list__item--expandable__actions a.btn--primary { + display: none; // No updates +} diff --git a/web/cobrands/westminster/favicon.ico b/web/cobrands/westminster/favicon.ico Binary files differnew file mode 100644 index 000000000..35c7ff782 --- /dev/null +++ b/web/cobrands/westminster/favicon.ico diff --git a/web/cobrands/westminster/images/chevron-black-right.png b/web/cobrands/westminster/images/chevron-black-right.png Binary files differnew file mode 100644 index 000000000..4375d7f71 --- /dev/null +++ b/web/cobrands/westminster/images/chevron-black-right.png diff --git a/web/cobrands/westminster/images/chevron-black-right.svg b/web/cobrands/westminster/images/chevron-black-right.svg new file mode 100644 index 000000000..5b24145ac --- /dev/null +++ b/web/cobrands/westminster/images/chevron-black-right.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="43" height="33"><path fill="#0D0E16" d="M0 0h13l13 16-13 16H0l13-16"/></svg>
\ No newline at end of file diff --git a/web/cobrands/westminster/images/collection-default-bg.jpg b/web/cobrands/westminster/images/collection-default-bg.jpg Binary files differnew file mode 100644 index 000000000..7ccfab873 --- /dev/null +++ b/web/cobrands/westminster/images/collection-default-bg.jpg diff --git a/web/cobrands/westminster/images/footer-facebook.png b/web/cobrands/westminster/images/footer-facebook.png Binary files differnew file mode 100644 index 000000000..72e74c606 --- /dev/null +++ b/web/cobrands/westminster/images/footer-facebook.png diff --git a/web/cobrands/westminster/images/footer-facebook.svg b/web/cobrands/westminster/images/footer-facebook.svg new file mode 100644 index 000000000..4dea6ce6f --- /dev/null +++ b/web/cobrands/westminster/images/footer-facebook.svg @@ -0,0 +1 @@ +<svg width="24" height="24" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.8 1v3.6h-2.1c-.8 0-1.3.1-1.6.4-.2.4-.4.8-.4 1.5V9h4l-.6 4h-3.4V23h-4V13H6.1V9h3.4V6c0-1.6.5-2.9 1.4-3.8A5 5 0 0 1 14.7 1l3 .1z" fill="#091F67" fill-rule="nonzero"/></svg>
\ No newline at end of file diff --git a/web/cobrands/westminster/images/footer-instagram.png b/web/cobrands/westminster/images/footer-instagram.png Binary files differnew file mode 100644 index 000000000..f1bdee818 --- /dev/null +++ b/web/cobrands/westminster/images/footer-instagram.png diff --git a/web/cobrands/westminster/images/footer-instagram.svg b/web/cobrands/westminster/images/footer-instagram.svg new file mode 100644 index 000000000..a3623fde1 --- /dev/null +++ b/web/cobrands/westminster/images/footer-instagram.svg @@ -0,0 +1 @@ +<svg width="24" height="24" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 19.1v-8.7H18l.3 1.8a6.2 6.2 0 0 1-3.2 5.4c-1 .5-2 .8-3.2.8-1.7 0-3.3-.6-4.5-1.8a5.9 5.9 0 0 1-1.6-6.2H4v8.7c0 .2 0 .4.2.6l.6.2h14.3c.3 0 .5 0 .6-.2l.3-.6zM16.2 12c0-1.1-.5-2-1.3-2.9A4 4 0 0 0 12 8 4 4 0 0 0 9.1 9C8.3 10 7.9 11 7.9 12a4 4 0 0 0 4.1 4 4 4 0 0 0 3-1.2c.7-.8 1.2-1.7 1.2-2.8zM20 7V5l-.3-.6A.9.9 0 0 0 19 4h-2.3c-.3 0-.5.1-.7.3l-.2.6v2.2c0 .3 0 .5.2.7.2.2.4.3.7.3H19c.3 0 .5-.1.7-.3.2-.2.3-.4.3-.7zm2.3-2.7v15.2c0 .8-.3 1.4-.8 2-.5.4-1.1.7-1.9.7H4.4c-.8 0-1.4-.3-2-.8-.4-.5-.7-1.1-.7-1.9V4.4c0-.8.3-1.4.8-2 .5-.4 1.1-.7 1.9-.7h15.2c.8 0 1.4.3 2 .8.4.5.7 1.1.7 1.9z" fill="#091F67" fill-rule="nonzero"/></svg>
\ No newline at end of file diff --git a/web/cobrands/westminster/images/footer-logo.png b/web/cobrands/westminster/images/footer-logo.png Binary files differnew file mode 100644 index 000000000..0131b9342 --- /dev/null +++ b/web/cobrands/westminster/images/footer-logo.png diff --git a/web/cobrands/westminster/images/footer-logo.svg b/web/cobrands/westminster/images/footer-logo.svg new file mode 100644 index 000000000..b06e03cb4 --- /dev/null +++ b/web/cobrands/westminster/images/footer-logo.svg @@ -0,0 +1 @@ +<svg width="200" height="49" viewBox="0 0 200 49" xmlns="http://www.w3.org/2000/svg"><g fill="#FFF" fill-rule="nonzero"><path d="M195 33.4c0-.3 0-.5-.3-.5l-.7.8c-.5.7-1.9 1.6-2.4 1.9-.3 0-.4.2-.4.4 0 .3.2.3.3.3.9.1 1.2.2 1.2 2.1v6c0 1.7 0 2-1 2.3-.6 0-.8.2-.8.5 0 .4.3.4.4.4l2.5-.1h2.8c.2 0 .5 0 .5-.4s-.3-.5-.8-.5c-1.3 0-1.3-1-1.3-2.2v-5.9c0-2.6.7-3 1.2-3 .2 0 .3 0 .8.6.3.6.7.8 1.2.8.6 0 1.5-.5 1.5-1.7 0-1.3-.9-2-1.8-2-1.4 0-2.5 1.9-2.6 2l-.2.2-.1-.4v-1.6zM84.7 36.4l-.2-.7.1-.4 1.6-4c.8-2 1-2.2 1.7-2.2.6 0 .9-.2.9-.6 0-.4-.3-.4-.6-.4l-1.3.1h-2c-.2 0-.4 0-.4.3a.4.4 0 0 0 .4.4c.5.1.8.3.8.7 0 .4-.1.7-.3 1L84 34.5l-1.4-4-.2-1.1c0-.3.2-.4.5-.4.2 0 .9 0 .9-.5a.4.4 0 0 0-.4-.4l-2.3.1h-2.6c-.2 0-.5 0-.5.3 0 .4.3.4.9.6.4 0 .6.4 1.1 1.9l2.4 6.6v.9l-1.9 4.7-4.4-12.7c-.2-.3-.2-.7-.3-1 0-.4.5-.5.6-.5.4 0 .7 0 .7-.5 0-.4-.3-.4-.6-.4l-2.5.1-2.4-.1c-.2 0-.5 0-.5.4s0 .4.9.6c.4 0 .7.2 1.3 1.8L79 47.3c.1.4.3.7.5.7s.3-.1.6-.9l2.9-7.8 2.5 8c.2.6.4.7.6.7.2 0 .3-.2.4-.6l5.1-15.6c.8-2.6 1.2-2.6 1.7-2.7.6-.1.9-.2.9-.6 0-.4-.3-.4-.4-.4l-1.7.1h-1.8c-.2 0-.6 0-.6.3 0 .4.2.4.4.5.8.1 1 .2 1 .7a9 9 0 0 1-.4 1.7L87 43l-2.2-6.5zM101.6 38.2c.3 0 .6 0 .6-.5 0-1-.8-4.5-4.4-4.5-3.2 0-6 3-6 7.5 0 4.4 2.5 7 5.6 7 3 0 4.8-2.7 4.8-3.1 0-.1 0-.3-.3-.3l-.5.5c-.6.8-1.6 1.2-2.6 1.2-2.5 0-4.3-2.6-4.3-6.1 0-1.6.3-1.7.7-1.7h6.4zm-6.1-1c-.6 0-.7 0-.7-.3 0-.5.8-3 3-3 .3 0 2 0 2 1.9 0 .8-.7 1.5-1.7 1.5h-2.6zM103.6 45.7c0 .8 0 1 .3 1.2.9.5 2 .8 3 .9 3.2 0 4.6-2.7 4.6-5 0-2.7-2-3.8-2.8-4.2-2.4-1.3-3-1.6-3-2.8a1.8 1.8 0 0 1 2-2c1.6 0 2.2 1.7 2.4 2.3.1.6.2.7.4.7s.3-.4.3-.5v-1.8c0-.5 0-.9-.5-1l-2.4-.3c-3.1 0-4.2 2.5-4.2 4.2 0 2.3 1.7 3.1 3.1 3.9 1.7.8 2.8 1.5 2.8 3.2 0 1.6-1.3 2.5-2.4 2.5-2 0-2.6-2.7-2.7-3.3-.1-.5-.3-.8-.5-.8-.3 0-.4.4-.4.5v2.3zM116.3 35.6c0-.6 0-.7.4-.7h2.1c.4 0 .4 0 .5-.6 0-.5-.1-.6-.3-.6h-2.1c-.5 0-.6 0-.6-.7v-2c0-.6-.3-.6-.3-.6-.2 0-.4.3-.4.4a7 7 0 0 1-2.2 2.8l-.6.4-.4.6c0 .3.3.3.4.3h.6c.4 0 .5.2.5.6v8.9c0 2.3 1.2 3.4 2.5 3.4 1.7 0 3.2-2 3.2-2.4 0-.1 0-.3-.2-.3l-.2.2c-.9.6-1.2.7-1.6.7-1.2 0-1.3-1.4-1.3-2.8v-7.6zM135.8 44.4c0 1.7 0 2-1 2.3-.5 0-.7.2-.7.5 0 .4.2.4.4.4l2.4-.1h2.5c.2 0 .4 0 .4-.3s-.1-.4-.7-.5c-1-.2-1-.6-1-2.3V37c0-1-.3-3.8-3.4-3.8-.8 0-1.7.4-2.7 1.4 0 .1-.6 1-.8 1l-.5-.8c-.4-1-1.4-1.6-2.5-1.6s-2 .2-3.3 2l-.3.3-.1-.6v-1.5c0-.3 0-.6-.3-.6-.2 0-.4.3-.5.6a7 7 0 0 1-2.5 2c-.2 0-.3.2-.3.4 0 .4.3.4.6.4.8.1.8.8.8 2.3v6c0 1.6 0 2-1 2.2-.6 0-.8.2-.8.5 0 .4.3.4.4.4l2.5-.1h2.4c.2 0 .5 0 .5-.3s-.1-.4-.7-.5c-1-.2-1-.6-1-2.3v-6.6c0-.7 0-1.3.4-1.7.4-.7 1-1 2-1 1.7 0 2 1.6 2 3.2v6c0 1.9 0 2.2-1 2.4-.5 0-.7.2-.7.5 0 .4.2.4.4.4l2.5-.1h2.4c.2 0 .4 0 .4-.3s0-.4-.7-.5c-1-.2-1-.6-1-2.3v-6.7c0-.6 0-1.3.4-1.8.5-.6 1.2-1 2-1 2 0 2 2 2 3v6.5zM145 29.8c0-1-.5-1.8-1.2-1.8-.8 0-1.4.9-1.4 1.7 0 1 .6 1.7 1.4 1.7.7 0 1.3-.8 1.3-1.6M142.6 44.4c0 1.7 0 2-1 2.3-.5 0-.7.2-.7.5 0 .4.2.4.4.4l2.5-.1h2.5c.1 0 .4 0 .4-.3s-.1-.4-.7-.5c-1-.2-1-.6-1-2.3v-10c0-.7 0-1.1-.3-1.1-.2 0-1 .8-1.1 1-.4.4-1 .8-2.1 1.3-.3 0-.4.2-.4.5s.4.4.4.4c.9 0 1.1.5 1.1 2v6zM156.6 44.4c0 1.7 0 2-1 2.3-.6 0-.8.2-.8.5 0 .4.3.4.4.4l2.5-.1h2.5c.2 0 .4 0 .4-.3s0-.4-.7-.5c-1-.2-1-.6-1-2.3v-7.2c0-3-1.7-4-3.3-4-1.4 0-2 .4-3.5 2.1l-.4.2v-2.1c0-.3-.1-.6-.3-.6-.2 0-.4.3-.6.6a7 7 0 0 1-2.4 2c-.3 0-.3.2-.3.4 0 .4.2.4.6.4.7.1.7.8.7 2.2v6c0 1.7 0 2-1 2.3-.5 0-.7.2-.7.5 0 .4.2.4.4.4l2.5-.2 2.5.2c.2 0 .4 0 .4-.4 0-.3-.1-.4-.7-.5-1-.2-1-.6-1-2.3v-6.6c0-.7 0-1.3.4-1.7.5-.7 1.2-1 2-1 2.4 0 2.4 2.2 2.4 3.7v5.6zM162.4 45.7c0 .8 0 1 .3 1.2 1 .5 2 .8 3 .9 3.2 0 4.7-2.7 4.7-5 0-2.7-2-3.8-2.8-4.2-2.5-1.3-3-1.6-3-2.8a1.8 1.8 0 0 1 2-2c1.6 0 2.2 1.7 2.3 2.3.2.6.3.7.5.7s.3-.4.3-.5v-1.8c0-.5 0-.9-.6-1l-2.3-.3c-3.2 0-4.2 2.5-4.2 4.2 0 2.3 1.7 3.1 3 3.9 1.7.8 2.8 1.5 2.8 3.2 0 1.6-1.2 2.5-2.3 2.5-2 0-2.6-2.7-2.8-3.3 0-.5-.2-.8-.5-.8s-.3.4-.3.5v2.3zM175.8 35.6c0-.6 0-.7.5-.7h2c.4 0 .5 0 .5-.6 0-.5 0-.6-.3-.6H176.4c-.5 0-.6 0-.6-.7v-2c0-.6-.2-.6-.3-.6-.2 0-.3.3-.4.4a7 7 0 0 1-2.1 2.8l-.6.4c-.2.2-.4.4-.4.6 0 .3.3.3.4.3h.5c.4 0 .6.2.6.6v8.9c0 2.3 1.2 3.4 2.4 3.4 1.8 0 3.3-2 3.3-2.4 0-.1 0-.3-.2-.3 0 0-.2 0-.3.2-.8.6-1.1.7-1.5.7-1.2 0-1.4-1.4-1.4-2.8v-7.6zM189.3 38.2c.3 0 .6 0 .6-.5 0-1-.8-4.5-4.4-4.5-3.2 0-6 3-6 7.5 0 4.4 2.5 7 5.6 7 3 0 4.7-2.7 4.7-3.1 0-.1 0-.3-.2-.3l-.5.5c-.6.8-1.6 1.2-2.6 1.2-2.5 0-4.2-2.6-4.2-6.1 0-1.6.2-1.7.6-1.7h6.4zm-6.1-1c-.6 0-.6 0-.6-.3 0-.5.7-3 2.9-3 .3 0 2 0 2 1.9 0 .8-.7 1.5-1.7 1.5h-2.6z"/><g><path d="M86.5 2.8c-.1-.9-.2-1.2-1.3-1.4-1.6-.4-2-.5-3.1-.5C75.9 1 73 6.6 73 11c0 4 2.6 10.2 8.7 10.2 2.4 0 4-1 4.4-1.3.3-.3.6-.7.5-1.2l.2-2.6c0-.3 0-.5-.3-.5s-.5.4-.5.6c-1 2.4-2 4.1-4.3 4.1-3.5 0-5.5-5-5.5-9.4 0-4 1.8-9.1 5.7-9.1 2.2 0 3 1.4 4 3.2.2.5.4.5.5.5.3 0 .3-.2.3-.5l-.2-2.2zM92.5 2.8c0-1-.6-1.8-1.3-1.8-.8 0-1.3.9-1.3 1.8s.5 1.7 1.3 1.7 1.3-.9 1.3-1.7M90 17.8c0 1.7 0 2-1 2.2-.5.1-.7.3-.7.5 0 .5.2.5.4.5l2.5-.2 2.5.2c.2 0 .4 0 .4-.4 0-.3-.1-.5-.7-.6-1-.2-1-.5-1-2.3V7.5c0-.7 0-1-.3-1-.2 0-1 .8-1.1 1-.4.3-1 .7-2.1 1.2-.3.1-.4.3-.4.5 0 .4.4.4.5.4.8.1 1 .5 1 2.2v6zM98 8.8c0-.7 0-.7.5-.7h2.1c.3 0 .4 0 .4-.6s0-.7-.3-.7H98.6c-.5 0-.6 0-.6-.6V4c0-.5-.2-.5-.3-.5l-.4.4a7.2 7.2 0 0 1-2 2.8l-.7.5c-.2 0-.4.3-.4.5 0 .4.3.4.4.4h.6c.3 0 .5.1.5.5v9.1c0 2.4 1.2 3.4 2.4 3.4 1.8 0 3.3-1.9 3.3-2.4 0 0 0-.2-.2-.2h-.3c-.8.8-1.1.9-1.5.9-1.2 0-1.4-1.5-1.4-2.9V8.8zM106 9.5c-.2-.3-.2-.6-.3-1 0-.5.3-.8.6-.8 1-.1 1-.1 1-.5 0-.3-.1-.4-.4-.4h-5c-.2 0-.6 0-.6.4l.1.3h.3c1 .2 1.1.2 1.7 2l3 10 .4 1.6-.2.9-.6 1.8c-.4 1-.7 1.1-1 1.1h-.8c-.8 0-1 .8-1 1.5 0 .4 0 1.3 1.3 1.3s1.4-.5 1.7-1.8l2.2-7.7c1-3.3 1.9-5.6 2.9-8.3.5-1.5.8-2.2 1.3-2.3.7 0 .9-.1.9-.5V7c-.2-.1-.3-.2-.4-.1h-3.8s-.4 0-.4.4.3.4.9.4c.6 0 .8.4.8 1l-.3 1.5-2.1 6.7-2.3-7.3zM135.8 8.8c0-.6.1-.7.6-.7h2.1c.3 0 .4 0 .4-.4 0-.7-.2-.9-.4-.9h-2.2c-.4 0-.5 0-.5-.7 0-1.2 0-5 1.5-5 .4 0 .7.3 1.3 1.5.1.2.5.7.8.7.4 0 1-1.5 1-2 0-1.2-1.7-1.3-2-1.3-1.5 0-4.3 1.6-4.7 5.4-.2 1.4-.3 1.5-.8 1.6-.6.2-.9.4-.9.8 0 .3.2.4.4.4l.6-.1c.4 0 .5 0 .5 1.1v8.5c0 1.3 0 2.1-1.2 2.3-.5 0-.8 0-.8.5s.3.5.5.5c.1 0 1-.2 2.7-.2 1.6 0 2.4.2 2.7.2.2 0 .5 0 .5-.5s-.4-.5-.9-.5c-1.2-.1-1.2-1-1.2-2.3V8.8zM130.5 13.4c0-4.7-3-7.1-5.8-7.1-3.5 0-5.8 3.8-5.8 7.4 0 3.6 2.2 7.4 6 7.4 3.2 0 5.6-3.5 5.6-7.7m-9-.3c0-3.8 1.3-6.1 3-6.1 2.7 0 3.5 4.6 3.5 6.7 0 3.8-1.5 6.7-3.2 6.7-2 0-3.4-3.3-3.4-7.3"/></g><g><path d="M30.8 14.5c1.8-.1 3.5-.1 5.2.1.7.2.3-.4.2-.5l-1.1-1H35l-.3-.2h-2.2c-1 0-1.8.2-2.7.4-.4.1 0 .4 0 .4a4.6 4.6 0 0 1 .6.8h.4M27.7 13.4a8 8 0 0 0 1-.3c.1-.1.1-.2-.1-.2h-1l-.3.1c.1.2 0 .5.4.4M23 16.7l-.2.2-.2.2-.4 2.8v2.5l.2.4s.1.3.3.3h2.7c1.6.1 3.1.3 3.4-.2.3-.4.2-1.4.4-2.6 0-2 .1-3-.1-3.4-.4-.4-2.1-.2-6.2-.2m5 2.6v1.1c0 .4-.4.6-.6.9-.2.2.1.7 0 .9-.2 0-.3 0-.5-.2-.2-.3-.5.3-1.1.3l-1-.5c-.2 0-.3 0-.5.2-.3 0 0-.4-.2-.8-.1-.3-.4-.1-.6-.5-.1-.4 0-.8.1-1l-.2-.6c-.1-.2.5 0 .7-.2.2-.2 0-.3.2-.7.2-.3.5-.2.9-.3.3 0 .3-.5.5-.7.2-.2.2.6.8.6.5 0 .9.1 1 .6.3.4.5.4.8.5.2 0 0 .1-.2.4"/><path d="M65.8 15.4h-.3c-.7 0-1.3.2-1.7.7l-.6 1.3-.1.3c0 .1 0 .2-.2.3h-.4l.3-.5v-.8c0-.2 0-.5.2-.7.1-.3 0-.7-.3-.8-.2 0-.4.1-.4.3l-.7 1-.8.8c-1.3 1.9-2.3 3.8-2 6 .2 1.6.7 2 2 3.5.9 1.6 1.8 2.8 1.4 4.2-.2.9-1.3.9-2.2.7.1-2.1-.4-3.2-1.4-5-1-1.8-2.4-2.8-3.6-4.5l-.1-.2v-.1c.2 0 .2-.2.3-.3.2.2.6.3.8.1.5-.4.2-1 .2-1.3l.1-.2.4-.1h.3c0 .1.8.1 1-.1.5-.4.2-1.4 0-2.2-.2-.8-.5-1-.4-1.6.4-.3 1 1 1.4.4.5-.5-.2-.8-.2-1.4 0-.2.7-.3.7-.7.1-.4-.5-1-.7-1.2h-.3V13l-.1-1.6-.5-1.3c0-.4.6-.7.6-1 0-.2-.4-.2-.6-.5-.3-.8.1-1.3-.3-1.4-.3 0-.3.2-.7.5-.2 0-.1-.4-.4-.5l-.5.3-.4.4-.4.2-.6.3-1.1.3c-.4 0-.7.2-1 .3-.7.4-.9.6-1.5 1.2-.2 0-.3.3-.3.5 0 .1 0 .3.2.4 0 .5 0 1 .2 1 .6.4 1.1 0 1.5.2 0 .2-.9.4-1.6.4l-1.1-.2h-.6l-.3-.1h-.2c-.1.1.3.3.7.6.5.3.4.7 1 .7.5 0 1.8-.7 1.8-.5.1.1-.4.3-.7.4-.5.3-1 0-1.2 1 0 .7 1.4 0 1.3.3 0 .3-.8.6-1.5.6a2 2 0 0 0-.6.5c-.1.1 0 .3-.2.4-.4.6-1.4.1-4.1-1a5.2 5.2 0 0 0-3.6-.5c-.2 0-.4.3-.4.5 0 .4.6.6 1 .4.4 0 .9-.1 1.2.3.3.3.1.5 0 1.2 0 .3.1.7.3 1l.4.5c.2.2.4 0 .5.1l.2.4.2.1c.2.2 0 .5.1.5.1.1.4 0 .6-.2v-.1l.3.3c0 .2 0 .5.3.6.3.3.6-.2.8 0 .1.1-.6 1-1.2 1.4-.4.5-.7.6-1.3 1.1-.5.6-.6 1.1-1 2.2-.2.4-.1.6-.6 1l.2-.9c.1-.4.1-.8.4-1 0-.1.3-.3.2-.4h-22l.3.3c.2.3.2.7.3 1.1.2.4.3.5.3 1-.5-.5-.5-.7-.6-1.1-.4-1-.5-1.6-1-2.2l-1.4-1c-.5-.6-1.3-1.4-1.2-1.5.2-.2.6.3.9 0 .2-.1.3-.4.3-.6l.2-.3c.3.2.5.4.6.3.2 0 0-.3.1-.5h.3l.1-.5h.6l.3-.6.4-1c-.2-.6-.3-.8 0-1.2.3-.4.7-.3 1.2-.2.3.1 1 0 1-.5 0-.2-.2-.4-.5-.5-1.2-.3-2.4 0-3.5.5-2.8 1.1-3.8 1.6-4.2 1l-.1-.4a2 2 0 0 0-.6-.5h-.1c-.7 0-1.5-.3-1.5-.6s1.3.4 1.3-.2c-.2-1.1-.6-.8-1.2-1-.3-.2-.8-.3-.7-.5 0-.2 1.3.6 1.8.6.6 0 .6-.5 1-.8.4-.3.9-.5.7-.5h-.2-.8c-.5 0-.7.3-1.2.2-.7 0-1.5-.2-1.6-.4.4-.1 1 .2 1.5-.1.3-.2.2-.6.3-1.1a.6.6 0 0 0-.2-1c-.6-.5-.8-.7-1.4-1l-1-.4-1.1-.2-.6-.4-.5-.2-.4-.4s-.4-.4-.5-.3c-.2 0-.1.4-.4.5-.4-.3-.4-.5-.7-.5-.4.1 0 .6-.3 1.5-.2.2-.6.2-.6.5 0 .2.6.6.6.9l-.5 1.3a5.1 5.1 0 0 0 0 1.8H7c-.2.3-1 .8-.8 1.2 0 .4.8.5.8.7 0 .6-.8 1-.3 1.4.6.6 1-.7 1.4-.4.1.6-.1.8-.4 1.6-.2.8-.4 1.7 0 2.2.2.2 1 .3 1 0h.3c0 .2.2.1.4.2l.2.2c0 .3-.4 1 .2 1.3.2.2.5 0 .7 0l.3.2v.3c-1.2 1.6-2.6 2.7-3.6 4.5-1 1.8-1.6 2.9-1.4 5-1 .2-2 .2-2.2-.7-.4-1.4.5-2.6 1.4-4.2 1.2-1.5 1.7-2 2-3.5.3-2.2-.7-4.1-2-6-.4-.2-.7-.5-.9-.8l-.6-1c-.1-.2-.3-.3-.5-.3-.3 0-.4.5-.2.8l.1.7v.8l.3.5h-.4a2.5 2.5 0 0 1-.3-.6c-.3-.7-.3-1-.6-1.3-.4-.5-1-.7-1.6-.8-.2 0-.4 0-.4.2v.2c.4.2.7.4 1 .8.5.9.2 1.7.9 2.4.9.9 2 1.5 3 1.9h.4l.4.9c.3.5.5.8.3 1.4-.3 1-.8 2-1.5 2.8-1.9 2.2-2.8 3.7-2.4 5.9.2 1.1 1.9 1.7 3.7 1l.4-.2h.1l.3.3s.2 0 .3.2c.4.3.6.9.4 1.4-.3 1-1 1.5-1.8 2.4v.2l-.4.1v.3l-.3.2c-.2.1 0 .4 0 .7 0 .4-.8.4-.7 1 0 .5.5.3.6.6.1.3-.8.5-.5 1.2.6.6 1.2.4 1.4 1l-.1.6c-.2.2-.2.5 0 .7 0 .2.2.1.6.1l.4-.2.7-.3c.1-.1.3-.1.4 0 .1 0 .2 0 .2.2-.3 0-1 .3-1.2.5-.7.3-1 .6-1.1.8-.2.5 0 .5.4 1.2.3.7.3 1.7.6 2 .4.3 1.2.2 2-.4 2.8-1.4 4.3-1.7 7-2.9l3.8-1.6c-.2.3-.3.5-.3.8 0 .8.2 1 0 2.3 0 .7 0 1 .2 1.5.5.7 1.5.5 2.7.5.8 0 5.7-.3 10.2-.3h1.6c4.5 0 9.4.3 10.2.3 1.2 0 2.2.2 2.7-.5.3-.5.3-.8.2-1.5-.2-1.3 0-1.5 0-2.3 0-.3-.1-.5-.3-.8l3.8 1.6c2.7 1.2 4.2 1.5 7 3 .8.5 1.7.6 2 .2.3-.3.3-1.2.6-1.9.4-.7.6-.8.4-1.2 0-.2-.4-.5-1-.8-.2-.2-1-.5-1.3-.5l.2-.3h.4l.7.3.5.3c.3 0 .4 0 .6-.2v-.6a.7.7 0 0 1-.2-.6c.2-.6.9-.4 1.4-1 .4-.8-.6-.9-.5-1.2.1-.3.6-.1.6-.7.1-.5-.6-.5-.7-1 0-.2.2-.5 0-.6l-.2-.2v-.3c-.2-.1-.3-.2-.4-.1l-.1-.2c-.7-.9-1.5-1.5-1.8-2.5-.2-.5 0-1 .4-1.4.1 0 .3 0 .3-.2 0 0 .2 0 .2-.2h.2c.1 0 .3 0 .4.2 1.8.7 3.5 0 3.7-1 .4-2.2-.6-3.7-2.4-6a7.5 7.5 0 0 1-1.5-2.8c-.2-.5 0-.9.3-1.4l.4-.8h.3a8 8 0 0 0 3.1-2c.7-.7.4-1.4 1-2.4l.8-.8c.1 0 .1-.2 0-.2m-12.7-5c.3-.2 1.1-.5 1.1-.3.2.8-.3.5-.6.7-.2.2-.8.6-.8.2 0-.3 0-.6.3-.7m-15.8 17c.4-.1.4-.4.5-.6 0-.3.5-.3.9-.2h.2l.3-.4c.1-.2.2-.3.4-.3.3.2.2.4.5.4s.7-.7 1-.3c.1.3-.3 1-.4 1.3l-.2.4c0 .3.1.6.3.7a.6.6 0 0 1 0 .9c-.2.4 0 .4.1.7v.4l-.6.8c-.3 0-.6-.4-.9-.2-.3.1-.6.7-1 .4-.3-.2-.3-.2-.6 0-.5.4-1 .6-.9 0 0-.4.2-.7.5-1 .2-.3-.3-.5.2-.8.7-.3.6-.6.5-.6h-.4l-.5.2c-.2 0-.4-.6-.1-.7.4-.1.8-.2.8-.4h-.6c-.2.3-.4.1-.5.1l-.1-.4c0-.4.3-.4.6-.5m-6.5.7l.5-1.1c0-.3.3-.6.2-.9 0-.3-.2-.6 0-.8.2-.4.7-.5 1-.5 1.4 0 1.4 1.3 1.5 1.5.2.3.8 0 1 .4v1c.1.6.7.3.8 1.5 0 .7-.3.7-.4 1.1l-.2.6v.3c0 .1-.3.5-.2.6l.2 1c.1.5.4.8.6 1.2v.7c0 .8 0 1.1-.2 1.2-.3.2-.4.2-1 .2a6 6 0 0 1-1 0c-.5-.2-1-.2-1.5 0-.4 0-.6.3-1 .3h-.5l-.3.2c-.1 0 0-.2-.2-.3v-.9c0-.6.2-1.2.4-1.8l.7-1.7-.1-.6v-.2c-.4-.1-.9-.3-1-.7-.5-.7.4-1.5.7-2.3m-.2 8.9c.7 0 1.2.5 1.6.3.8 0 1.1-.3 1.9-.3h.5c.7.2 1.2.4 1.7.4l1.4-.2c.2.4 0 .5-.4 1l-.3.2h-.2c-.7.2-.7 0-1.4 0L34 38h-.3c-.3 0-.9.3-1.5.3-.7 0-1.4-.5-1.7-.3-.6 0-1 .4-1.5.3-.3 0-.5-.2-.7-.3-.4-.5-.5-.6-.4-1l1.2.1 1.4-.2m3.7 2.5c-.3-.1-.8.2-1.3.2h-.2c-.5 0-1-.3-1.2-.2-.5 0-.7.3-1.1.2l-.6-.2c-.3-.4-.4-.5-.3-.8l1 .1 1-.2c.6 0 1 .4 1.3.3h.2l1.2-.2 1 .1h1c.1.2 0 .3-.3.6-.1.2-.3.2-.5.3-.5 0-.7-.2-1.2-.3m.4 1.3l-.2.2c-.3.1-.4-.1-.6-.2l-.6.2-.5-.1h-.1c-.2 0-.4.3-.6.2a.5.5 0 0 1-.3-.2c-.1-.3-.2-.4-.1-.6h.5l.4-.1.7.2.6-.2.5.1h.5l-.2.5m-9.5-13.3c.3 0 .3-.3.4-.5.2-.3.5-.3 1-.2h.2l.3-.4c.1-.2.1-.3.3-.3.3.2.2.4.6.4.3 0 .7-.7 1-.3.1.3-.3 1-.4 1.3l-.2.4c0 .3 0 .6.2.7a.6.6 0 0 1 .1.9c-.3.4 0 .4 0 .7.1.1 0 .3 0 .4-.1.2-.3.7-.6.8-.2 0-.5-.4-.8-.2-.3.1-.6.7-1 .4-.3-.2-.3-.2-.7 0-.5.4-.9.6-.8 0 0-.4.2-.7.5-1 .2-.3-.3-.5.2-.8.6-.3.5-.6.5-.6h-.4l-.5.2c-.3 0-.4-.6-.2-.7.5-.1.8-.2.8-.4 0-.1 0 0-.2 0H25c-.2.3-.3.1-.4.1l-.2-.4c0-.4.4-.4.7-.5m-13.4-17c0-.2.8 0 1.2.1.3.2.2.5.2.7 0 .4-.5 0-.8-.2-.3-.2-.7 0-.6-.7M18 39l-3 1.4-2.3 1-1.7.6a5.8 5.8 0 0 0-1.2.6l.1-.4c.4-.4.5-1 .3-1.3-.4-.6-1.9-.4-2.4-.2h-.2-.2v-.2a9.8 9.8 0 0 1 2.8-3.1c2.2-1.7 3.1-3 3-3.8-.1-.5-.3-1-.6-1.4h.5c.4-.1.7 0 1 .4.1.2 0 .2 0 .4 0 .4-.7 2.5.4 3.4 1.1 1.1 2.7.5 3.2 1 .3.3.3.9.7.9.4 0 .5-.5.8-.6.4 0 .7.3 1 .5l-2.2.8m3.9-1.3l-1.4.4c.2-.3.4-.6.6-.6.3 0 .5.2.8 0v.2m22.2 0v-.1c.3.1.6-.1.8-.1.2 0 .4.3.7.6a38 38 0 0 0-1.5-.4m1.6-2.4a.5.5 0 0 0-.4-.2l-.7-.2c-.4 0-.8.1-1.1.5a4 4 0 0 1-.5.6l-.5.5c-.1.1-.2.2-.1.3v.1l.4.1-.2.3c-.2 0-.5 0-.6.2-.5.4-.5 1-.7 1.7-.2.8-.6 1-.6 1.4.2.7.6.9 1 1.3.5.3.8.7 1.1 1.2l.3.4-1.6-.2a54.2 54.2 0 0 0-6.6 0h-2 .3-8.6l-1.7.2.3-.4c.3-.5.7-.9 1.1-1.2.5-.4.8-.6 1-1.3.1-.4-.3-.6-.5-1.4-.2-.7-.3-1.3-.7-1.7a.7.7 0 0 0-.6-.2l-.2-.2V37h.3l.1-.2c0-.1 0-.2-.2-.3l-1-1.1c-.2-.4-.6-.6-1-.5-.3 0-.5 0-.7.2l-.4.2-.1.1H20l-.1.1c-1.3-.3-2-.7-2.5-1.9-.8-2 .2-4-.8-5-.9-.6-1.6-.3-2.4 0l-1 .3h-.9l.6-.8c.7-.7 1.5-.6 2.3-1.3.8-.7 1.6-2.4 1.7-2.2.2 0-.2 1 .6 1.1.5.1.8-.7 1-.6.2 0 .3.3.3.5 0 .4 0 .8-.3 1.1-.2.2-.3.8-.2 1 .2.2 1.2-.5 1.4-.4.2.1 0 1.2.6 1.3.3 0 .5 0 .7-.3l.2-.3c.1.3.2.5.6.4.4 0 .4-.5.4-.7.1-.3.3-.2.3 0v1.4c0 1.7.6 3.4 1.4 4.9 1.5 2.5 3.4 4.1 5.5 5.8l1.4 1.2 1.5 1.1c.2.2.2.3.4.3h.4l.4-.3c.6-.3 1-.7 1.5-1.2l1.4-1c2.1-1.8 4-3.4 5.5-6a10.9 10.9 0 0 0 1.4-6.2c0-.2.3-.3.3 0 0 .2 0 .6.5.7.3 0 .5-.2.6-.5l.2.3c.1.3.4.4.7.3.5 0 .4-1.1.6-1.2.2-.1 1.1.6 1.3.4.2-.2 0-.8-.2-1-.2-.4-.3-.7-.2-1.1l.2-.6c.2 0 .6.7 1.1.7.7-.1.4-1.2.5-1.2.1-.2 1 1.5 1.8 2.3.8.6 1.5.5 2.3 1.2l.6.8h-.8-.1l-1-.2c-.8-.3-1.6-.6-2.4 0-1 1 0 3-.8 5-.5 1.1-1.3 1.6-2.6 1.9l-.2-.1-.1-.1m12.9 5.1v.2l-.2.1h-.2c-.5-.3-2-.5-2.3.1-.2.3-.1 1 .2 1.4v.3L55 42l-1.7-.6c-1-.3-1.4-.6-2.2-1l-3.1-1.5-2.2-.8c.3-.1.6-.5 1-.4.4 0 .4.6.8.5.4 0 .4-.6.7-.9.5-.5 2.1.2 3.2-1 1-.8.5-3 .4-3.4 0-.2-.1-.2 0-.4a1 1 0 0 1 1-.4h.5c-.3.4-.4 1-.5 1.4-.2.8.7 2.1 3 3.8 1 .8 2 1.9 2.7 3.1"/><path d="M29.2 13.8c-.3-.2-.2-.3-.4-.3l-1.6.3c-1.9.6-2.2 1-2.1 1.6 0 .6 1.2.4 1.6.2a31 31 0 0 1 2.7-1c.8 0 .2-.5-.2-.8M43 9.1c-.8-1.3-1-1.9-1.5-2.2-.2-.1-.4 0-.6.2-.5-.2-.8-.5-1.1-.2-.2.2 0 .3 0 .5-.4.3-.7.7-.7 1 .2.4.7.6 1.5 2.2.6 1.1 1.2 0 1.3.4 0 .2-.6.3-.6.9.3.8.8 1.5 1.3 2.2.2.2.3.5.5.5.2.1.7-.2.8-.3.3 0 .6.5.9.3.2-.2 0-.3.1-.6.2-.3.6-.3.6-.6l-.1-.7c-1.2-1.5-1-2-1.5-2.3-.3-.1-.6.3-1 .2l-.1-.4c.2-.4.4-.3.4-.6L43 9m-1.4.3c-.1.7-.3.9-.4.8-1-1.3-1.4-1.3-1.2-1.6.3-.3.4-.3.7.1.1 0 .3 0 .3-.2s-.2-.3-.1-.5c.3 0 .6-.2.7 0 .3.4.9 1 .6 1.5-.3.2-.6-.3-.6 0m1.5 2.8c.1-.3 0-.8.2-.9.3 0 .5.3.8.7 0 .2.5 1 .2 1-.3.2-.6-.7-.7-.4.2.6 0 .6-.2.7-.5.3-.7-.2-1-.5-.2-.7-.4-.7-.1-1 .4-.3.5.3.8.4M25.5 13.4s0-.1-.2 0l-1 .1s-.3.2-.1.4c0 .2.3.6.5.5l1-.4c.2 0 .2-.2 0-.4 0-.2-.2-.2-.2-.2M26.4 13.8c.5-.1 1-.2.8-.5-.1-.3-.2-.3-.4-.3l-.7.2c-.3 0-.4.1-.2.4.1.2 0 .4.5.2M40.4 3.3c-.4.7-.6.8-.7 1.4a1 1 0 0 0 1.1 1c.6 0 1-.4 1.1-.6.6-.4 1 0 1.1-.2.3-.3-.3-1-.2-1.3 0-.4.5-1 .2-1.3-.3-.1-1 .3-1.5.3s-.5-.3-1-.2c-.3 0 .2.4 0 .9M24.5 5.7c1.5-.1 3-3.5-.3-3.2l-.9.2c-1.4.5-2.2 3.4 1.1 3m.4-2.3l.1.1c.6.6-.6 1.7-1.3 1.2-1-.6.4-1.9 1.2-1.3M29.8 23h1a139.4 139.4 0 0 0 3.6 0H36.1c.3-.2.5-.2.4-2l-.2-3.6c0-.2 0-.5-.2-.7l-.8-.3H33l-1 .1-1.9.1c-.3.1-.4.2-.5.6l-.2 3.2v2c0 .2.1.5.4.6m2.4-4c.2 0 0-.9 0-1.2 0-.3 0-.8.7-.7.6 0 .5.6.5.8v1.2c.2.1 2.3-.3 2 .5 0 .4.3.6-1 .6-.3.1-1 0-1 .5l-.1 1.1v.2c-.2.2-.4.4-.7.4-1.1.1 0-2.2-.5-2.2 0-.4-2.3.4-2.2-.6.2-1 2-.4 2.3-.6M41.6 13.9c.2-.2 0-.3-.2-.4l-1-.2-.3.3c-.2.2-.1.4 0 .4l1 .4c.2.1.4-.3.6-.5M26.3 27.6s.6-.2.7-.4c0-.2-.3-.3-.5-.2-.2 0-.5.2-.5.3 0 .2.1.3.3.3M38.1 13.4c.3 0 .3-.2.4-.4 0-.2-.2-.2-.4-.2h-.9c-.2.1-.2.2-.1.3l1 .2M37 23c.3.4 1.9.2 3.5.2 1-.2 2.1 0 2.9 0l.2-.5v-.3-2.5c0-1-.1-1.9-.3-2.8-.1-.2-.1-.3-.2-.2-4 0-6-.4-6.3 0-.3.5-.2 1.4 0 3.4 0 1.2 0 2.2.3 2.6m.6-4c.2-.1.5 0 .7-.5.2-.5.6-.5 1-.6.7 0 .7-.8.9-.6.2.2.2.7.5.7.4.1.6 0 .9.3.2.4 0 .5.2.7.2.2.8 0 .7.2 0 .2-.3.3-.2.6 0 .2.2.6 0 1-.1.4-.4.2-.5.5-.2.4 0 .9-.2.8-.2-.1-.3-.3-.5-.2-.2.1-.5.4-1 .5-.6 0-1-.5-1.1-.3-.2.2-.4.3-.5.2-.1-.2.2-.7 0-1-.2-.2-.6-.4-.6-.8v-1c-.3-.4-.5-.5-.3-.5M37.2 14.8h.2l2 .9c.5.1 1.6.4 1.7-.3 0-.5-.3-1-2.1-1.5-1.6-.5-2.8-.7-3-.7-.1 0-.2.1-.1.2.5.3.9.7 1.2 1.2l.1.2M40 13.6c.1-.3 0-.4-.2-.4L39 13c-.2 0-.2 0-.4.3s.4.4.8.5c.5.2.4 0 .6-.2"/><path d="M21 14c0 .3 0 .5.2.6.2.3.5-.2.8-.2.1 0 .6.4.8.2.2 0 .3-.3.5-.5.5-.6 1-1.4 1.3-2.2 0-.6-.7-.6-.6-.9.1-.3.7.7 1.3-.3.8-1.6 1.3-1.9 1.4-2.3 0 1.2.2 2 .3 3.2a5.5 5.5 0 0 0 .4 1c.1.1.1 0 .4 0 .5-.2 1-.3 1.1-.6 0-.2 0-.2-.4-.2-.2 0 0-1 0-1.1h1c0 .1.3.8.1.8-.6.2-.4.3.2.8.3.2.4.7.6.7.2 0 .2-.3.6-.6.1-.2.8-.7.1-.7-.1 0 0-1 .2-1l1 .1c0 .2.2.7-.2.7-.3 0-.3.3.3.8.2.1.4.5.6.4.2 0 .5-.4.8-.6 0 0 .5-.4 0-.5l-.3-.1.1-.8h1c.2-.1.3.1.3.4.1.5 0 .4-.2.4l-.2.4.7.5c.4.2.2.4.5.5l.6-.5c.3-.2 1-.5.4-.8-.3-.1-.3-.2-.3-.5 0-.1 0-.3.2-.4.2-.2.7 0 1 0 .1 0 .1.8-.2.8-.6.1-.1.3 0 .5l.6.5.3.2c.2.1.4-.8.6-1.1 0-1.7.3-2.5.2-4.2-.2-2.2 0-4.3.2-4.3.3-.3 2-1.3.8-2.6-.6-.7-2-.3-2.2 1l-.6.2h-2.7l-1.7.1h-4.2l-.7-.1v-.3C27.5.2 26.3-.2 25.6.5c-1.1 1.2.6 2.3 1 2.6.1 0 .3 2 .1 4.2v.7a2 2 0 0 0-.6-.7V7c-.4-.3-.7 0-1.1.2-.3-.1-.5-.3-.7-.2-.5.3-.7.9-1.4 2.2-.2.3-.3.3-.3.5 0 .3.2.2.4.6-.1.3 0 .4-.2.4-.2.1-.6-.3-.8-.2-.6.4-.4.8-1.5 2.4-.2.1-.3.4-.2.6 0 .3.4.3.6.6M39.4.8h.2c.4.6 0 1.3-.6 1.2-1.1-.3-.2-1.7.4-1.2m-1.3 8.8l-2-.1.1-1.7c.1-.2 1.7-.1 1.8 0 0 0 .3 1.8 0 1.8m-1.7-7a5.8 5.8 0 0 1 1.7-.1v.3c-.1.6.2 1.2-.2 1.5h-1.3-.1v-.1c.1-.9-.2-1.5 0-1.6m-.2 2.7c.2-.3.9 0 1.6-.2a.1.1 0 0 1 .2.2c-.1.9.3 1.7 0 1.7-.7.2-1.8.2-1.8 0V5.3m-2.7-2.6v-.1h.1c.8 0 1.5-.2 1.7 0v1.8c0 .2-1.1.2-1.7 0-.4 0 0-.8 0-1.7m0 2.8c-.1 0 0 0 0-.2h1.6c.1 0 0 .2 0 .4v.7.6h-1.7c-.2 0 0-.8 0-1.6m-.1 2.4c0-.2 1.2-.1 1.9-.1.2 0 0 1.8-.1 1.8h-1.8V7.7m-2.8-2.4v-.2h1.6c.3.2 0 .9.1 1.5V7L30.5 7V5.4m1.9 2.8l-.1.7v.6h-1.7c-.2 0 0-.8 0-1.6h.1c0-.2 1.4-.2 1.6-.1.1 0 0 .1 0 .4m-1.8-5.5v-.2h.1c.8.1 1.4 0 1.6.2a6 6 0 0 1 0 1.6c0 .3-1 .3-1.7.1-.3 0 0-.9 0-1.7M28 2.5s1.5 0 1.6.2V4s.1.3 0 .4H28c-.3-.3 0-1-.2-1.6v-.2m0 2.8a.2.2 0 0 1 .1-.2h.1c.8.1 1.4-.1 1.6.1V7c0 .2-1.1.2-1.7.1-.3 0 0-.9 0-1.7m-.1 2.4l1.8.1v1.6c0 .2-1.2.1-1.9.2-.2 0 0-1.8.1-1.9m-1-5.8c-.6.2-1-.5-.6-1.1h.2V.7h.2c.4-.3 1.4 1 .2 1.2m-2.6 6c.1-.2.5 0 .8 0 0 .2-.3.3-.2.5l.3.2c.3-.4.4-.4.7 0 .2.2-.2.2-1.1 1.5-.2.1-.4-.1-.4-.7 0-.3-.4.2-.7 0-.3-.6.3-1.1.6-1.5m-2.4 4c.2-.3.4-.6.7-.6.2 0 0 .6.2.9.3 0 .4-.6.8-.3.3.2 0 .2-.2.9-.2.3-.4.8-.9.6-.2-.1-.4-.2-.2-.7 0-.4-.4.5-.7.4-.2-.2.1-.8.3-1.1"/></g></g></svg>
\ No newline at end of file diff --git a/web/cobrands/westminster/images/footer-twitter.png b/web/cobrands/westminster/images/footer-twitter.png Binary files differnew file mode 100644 index 000000000..e70085637 --- /dev/null +++ b/web/cobrands/westminster/images/footer-twitter.png diff --git a/web/cobrands/westminster/images/footer-twitter.svg b/web/cobrands/westminster/images/footer-twitter.svg new file mode 100644 index 000000000..092ec907e --- /dev/null +++ b/web/cobrands/westminster/images/footer-twitter.svg @@ -0,0 +1 @@ +<svg width="24" height="24" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22.6 5.5a9 9 0 0 1-2.2 2.2v.6a12.3 12.3 0 0 1-2 6.8c-.8 1-1.6 2-2.5 2.8a12.6 12.6 0 0 1-14.4.7h1c2 0 3.8-.5 5.4-1.8-1 0-1.8-.3-2.6-.8a4.2 4.2 0 0 1-1.5-2.2l.8.1 1.2-.1c-1-.3-1.9-.7-2.5-1.5-.7-.8-1-1.7-1-2.8.6.3 1.3.5 2 .5a4.3 4.3 0 0 1-1.4-5.8 12.3 12.3 0 0 0 9 4.5l-.2-1c0-1.1.5-2.2 1.3-3 .8-.8 1.9-1.3 3-1.3 1.3 0 2.4.5 3.2 1.4 1-.2 2-.5 2.8-1-.4 1-1 1.8-2 2.3.9 0 1.7-.3 2.6-.6z" fill="#091F67" fill-rule="nonzero"/></svg>
\ No newline at end of file diff --git a/web/cobrands/westminster/images/search-icon.png b/web/cobrands/westminster/images/search-icon.png Binary files differnew file mode 100644 index 000000000..8a2937508 --- /dev/null +++ b/web/cobrands/westminster/images/search-icon.png diff --git a/web/cobrands/westminster/images/wcc_logo.png b/web/cobrands/westminster/images/wcc_logo.png Binary files differnew file mode 100644 index 000000000..9144bf5b1 --- /dev/null +++ b/web/cobrands/westminster/images/wcc_logo.png diff --git a/web/cobrands/westminster/layout.scss b/web/cobrands/westminster/layout.scss new file mode 100644 index 000000000..7864f37ac --- /dev/null +++ b/web/cobrands/westminster/layout.scss @@ -0,0 +1,148 @@ +@import "colours"; +@import "../sass/layout"; + +#site-logo { + margin: 0; // reset the centering we introduced in the mobile view +} + +#site-header { + padding-top: 1.25em; + padding-bottom: 1.25em; +} + +#front-main { + background-color: $westminster_grey; + padding: 50px 0; + + label { + font-size: 18px; + } + + h1 { + font-size: 40px; + } + + #postcodeForm { + overflow: visible; + margin: 0; + + div { + margin: 0; + overflow: visible; + + input#pc { + float: none; + height: auto; + width: 100%; + padding: 10px 22px; + } + + input#sub { + float: none; + height: auto; + width: 0; + padding-top: 0; + } + } + } + + #front-main-container { + padding: 0 1em; + } +} + +#front-main a#geolocate_link { + color: $westminster-blue; +} + +body.mappage { + #site-header { + box-sizing: border-box; // count padding as part of height, so border-bottom is visible + padding-top: 18px; // 1.25em minus 3px, to visually compensate for 3px border-bottom + } + + .nav-wrapper { + top: -3px; // to visually compensate for 3px border-bottom on #site-header + } + + .westminster-footer { + display: none; + } +} + +.nav-menu--main { + li { + color: $westminster_navy; + } + + a, span, a:visited { + padding: 0.25em 0.5em; + margin: 0.5em 0.25em; + color: inherit; + font-weight: bold; + } + + a { + &:hover, + &:focus { + background: transparent; + color: inherit; + } + } + + // Make it look unclickable (because it is). + span.report-a-problem-btn:hover { + color: inherit; + cursor: default; + } + + // Make it not look like a button any more. + a.report-a-problem-btn, + a.report-a-problem-btn:hover { + color: inherit; + background: transparent; + padding: 0.25em 0.5em; + margin: 0.5em 0.25em; + } +} + +.big-green-banner { + color: $westminster-black; + background: $col_click_map inline-image("../westminster/images/chevron-black-right.svg") $right center no-repeat; + + .ie8 & { + background-image: url(/cobrands/westminster/images/chevron-black-right.png); + } +} + +.problem-header h1 { + font-weight: bold; +} + +.westminster-footer { + text-align: inherit; +} + +.westminster-footer-logo { + a { + $image-width: 200px; + $image-height: 49px; + float: right; + margin-top: 70px; + width: $image-width; + padding-top: $image-height; + background-size: $image-width $image-height; + } +} + +.westminster-site-links { + margin-top: 0; + + li { + margin-bottom: 0.5em; + } +} + +.westminster-tertiary-links { + text-align: center; +} diff --git a/web/js/geolocation.js b/web/js/geolocation.js index fbef4d7ea..63599d4c1 100644 --- a/web/js/geolocation.js +++ b/web/js/geolocation.js @@ -34,7 +34,7 @@ fixmystreet.geolocate = function(element, success_callback) { fixmystreet.geolocate(link, function(pos) { var latitude = pos.coords.latitude.toFixed(6); var longitude = pos.coords.longitude.toFixed(6); - var coords = 'latitude=' + latitude + ';longitude=' + longitude; + var coords = 'lat=' + latitude + '&lon=' + longitude; location.href = link.href + (link.href.indexOf('?') > -1 ? ';' : '?') + coords; }); } else { |