From ea311c6905ebf0f8e686799e51a7556756345d27 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Wed, 18 Feb 2026 16:13:24 +0000 Subject: [PATCH 01/11] [Rutland] Prepare Rutland for Confirm integration as Multi Set up the Multi configuration for Rutland. Although contains just Saleforce is ready for Confirm to run alongside. Removes out of date comment about Bexley as all Cobrands now send service_code. --- perllib/Open311/Endpoint/Integration/Multi.pm | 2 - .../Endpoint/Integration/UK/Rutland.pm | 31 ++++++++----- .../Rutland.pm => UK/Rutland/SalesForce.pm} | 32 ++++++++++---- t/open311/endpoint/rutland.t | 44 +++++++++---------- t/open311/endpoint/uk.t | 6 ++- 5 files changed, 70 insertions(+), 45 deletions(-) rename perllib/Open311/Endpoint/Integration/{SalesForce/Rutland.pm => UK/Rutland/SalesForce.pm} (92%) diff --git a/perllib/Open311/Endpoint/Integration/Multi.pm b/perllib/Open311/Endpoint/Integration/Multi.pm index 04b23b24b..6bb7730a5 100644 --- a/perllib/Open311/Endpoint/Integration/Multi.pm +++ b/perllib/Open311/Endpoint/Integration/Multi.pm @@ -161,8 +161,6 @@ sub get_token { sub post_service_request_update { my ($self, $args) = @_; - # Cobrand needs to send the service_code through with updates - # (see Bexley's open311_munge_update_params in FMS for example) my ($integration, $service_code) = $self->_map_from_new_id($args->{service_code}, 'service'); my ($integration2, $service_request_id) = $self->_map_from_new_id($args->{service_request_id}, 'request'); die "$integration did not equal $integration2\n" if $integration ne $integration2; diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland.pm index 3f038cfa4..2181479ac 100644 --- a/perllib/Open311/Endpoint/Integration/UK/Rutland.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland.pm @@ -1,25 +1,32 @@ package Open311::Endpoint::Integration::UK::Rutland; -use parent 'Open311::Endpoint::Integration::SalesForce::Rutland'; use Moo; +extends 'Open311::Endpoint::Integration::Multi'; + +use Module::Pluggable + search_path => ['Open311::Endpoint::Integration::UK::Rutland'], + instantiate => 'new'; has jurisdiction_id => ( is => 'ro', default => 'rutland', ); -sub reverse_status_mapping { - my ($self, $status) = @_; +sub service_request_content { + '/open311/service_request_extended' +} - my %valid_status = map { my $no_spaces = $_; $no_spaces =~ s/\s+/_/g; $_ => $no_spaces; } ( - 'open', 'investigating', 'in progress', 'planned', 'action scheduled', - 'no further action', 'not councils responsibility', 'duplicate', 'internal referral', - 'fixed', 'closed', - ); +=pod - $valid_status{'not responsible'} = 'not_councils_responsibility'; +Rutland was previously only a Salesforce backend in open311-adapter, so we +maintain its categories/IDs without any backend prefix as any updates on pre-multi +reports will be looking for the id without the prefix - return $valid_status{lc($status)} || 'open'; -} +=cut + +has integration_without_prefix => ( + is => 'ro', + default => 'SalesForce', +); -1; +__PACKAGE__->run_if_script; diff --git a/perllib/Open311/Endpoint/Integration/SalesForce/Rutland.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm similarity index 92% rename from perllib/Open311/Endpoint/Integration/SalesForce/Rutland.pm rename to perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm index 67563d34c..115740686 100644 --- a/perllib/Open311/Endpoint/Integration/SalesForce/Rutland.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm @@ -1,8 +1,4 @@ -package Open311::Endpoint::Integration::SalesForce::Rutland; - -use Moo; -extends 'Open311::Endpoint'; -with 'Open311::Endpoint::Role::mySociety'; +package Open311::Endpoint::Integration::UK::Rutland::SalesForce; use Open311::Endpoint::Service::UKCouncil::Rutland; use Open311::Endpoint::Service::Request::SalesForce; @@ -15,6 +11,28 @@ use Encode qw(encode_utf8); use Digest::MD5 qw(md5_hex); use DateTime::Format::Strptime; +use Moo; +extends 'Open311::Endpoint'; +with 'Open311::Endpoint::Role::mySociety'; + +has jurisdiction_id => ( + is => 'ro', + default => 'rutland_salesforce', +); + +sub reverse_status_mapping { + my ($self, $status) = @_; + + my %valid_status = map { my $no_spaces = $_; $no_spaces =~ s/\s+/_/g; $_ => $no_spaces; } ( + 'open', 'investigating', 'in progress', 'planned', 'action scheduled', + 'no further action', 'not councils responsibility', 'duplicate', 'internal referral', + 'fixed', 'closed', + ); + + $valid_status{'not responsible'} = 'not_councils_responsibility'; + return $valid_status{lc($status)} || 'open'; +} + sub service_request_content { '/open311/service_request_extended' } @@ -37,8 +55,6 @@ sub parse_datetime { return $strp->parse_datetime($time); } -sub reverse_status_mapping {} - has '+request_class' => ( is => 'ro', default => 'Open311::Endpoint::Service::Request::SalesForce', @@ -277,4 +293,4 @@ sub service { return $service; } -__PACKAGE__->run_if_script; +1; diff --git a/t/open311/endpoint/rutland.t b/t/open311/endpoint/rutland.t index aec3856d2..2ed37338a 100644 --- a/t/open311/endpoint/rutland.t +++ b/t/open311/endpoint/rutland.t @@ -46,10 +46,10 @@ use Data::Dumper; use JSON::MaybeXS; BEGIN { $ENV{TEST_MODE} = 1; } -use Open311::Endpoint::Integration::UK; +use Open311::Endpoint::Integration::UK::Rutland; use Integrations::SalesForce::Rutland; -my $endpoint = Open311::Endpoint::Integration::UK->new; +my $endpoint = Open311::Endpoint::Integration::UK::Rutland->new; my %responses = ( 'new_report' => '[{ "Id": "12345" }]', @@ -236,7 +236,7 @@ subtest "create basic problem" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/requests.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', service_code => 'POT', address_string => '22 Acacia Avenue', @@ -255,7 +255,6 @@ subtest "create basic problem" => sub { ok $res->is_success, 'valid request' or diag $res->content; - is_deeply decode_json($sent), [{ "detail__c" => "description", @@ -287,7 +286,7 @@ subtest "create problem with extra categories" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/requests.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', service_code => 'POT', address_string => '22 Acacia Avenue', @@ -340,7 +339,7 @@ subtest "create problem with extra list categories" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/requests.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', service_code => 'RC_08', address_string => '22 Acacia Avenue', @@ -393,7 +392,7 @@ subtest "create problem with multiple photos" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/requests.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', service_code => 'POT', address_string => '22 Acacia Avenue', @@ -447,7 +446,7 @@ subtest "create problem with unrecognised attribute" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/requests.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', service_code => 'POT', address_string => '22 Acacia Avenue', @@ -489,7 +488,7 @@ subtest "create problem with unrecognised attribute" => sub { subtest "check fetch problem" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( - GET => '/requests.json?jurisdiction_id=rutland&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', + GET => '/requests.json?jurisdiction_id=rutland_salesforce&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', ); my $sent = pop @sent; @@ -554,13 +553,12 @@ subtest "check fetch problem with not responsible status" => sub { }]', my $res = $endpoint->run_test_request( - GET => '/requests.json?jurisdiction_id=rutland&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', + GET => '/requests.json?jurisdiction_id=rutland_salesforce&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', ); my $sent = pop @sent; ok $res->is_success, 'valid request' or diag $res->content; - is_deeply decode_json($res->content), [ { address => '', @@ -619,7 +617,7 @@ subtest "check fetch problem ignores problems older than start date" => sub { }]', my $res = $endpoint->run_test_request( - GET => '/requests.json?jurisdiction_id=rutland&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', + GET => '/requests.json?jurisdiction_id=rutland_salesforce&start_date=2018-01-10T00:00:00Z&end_date=2018-01-10T23:59:59Z', ); my $sent = pop @sent; @@ -669,7 +667,7 @@ subtest "check fetch problem works with no start date" => sub { }]', my $res = $endpoint->run_test_request( - GET => '/requests.json?jurisdiction_id=rutland', + GET => '/requests.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -699,7 +697,8 @@ subtest "create update" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/servicerequestupdates.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', + service_code => 'code', api_key => 'test', service_request_id => "a086E000001gcVRQAY", updated_datetime => "2014-01-01T12:00:00Z", @@ -732,8 +731,9 @@ subtest "create update with unicode" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( POST => '/servicerequestupdates.json', - jurisdiction_id => 'rutland', + jurisdiction_id => 'rutland_salesforce', api_key => 'test', + service_code => 'code', service_request_id => "a086E000001gcVRQAY", updated_datetime => "2014-01-01T12:00:00Z", update_id => 1234, @@ -764,7 +764,7 @@ subtest "create update with unicode" => sub { subtest "check fetch updates" => sub { set_fixed_time('2014-01-01T12:00:00Z'); my $res = $endpoint->run_test_request( - GET => '/servicerequestupdates.json?jurisdiction_id=rutland', + GET => '/servicerequestupdates.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -792,7 +792,7 @@ subtest "check fetch update with no comment" => sub { }]'; my $res = $endpoint->run_test_request( - GET => '/servicerequestupdates.json?jurisdiction_id=rutland', + GET => '/servicerequestupdates.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -820,7 +820,7 @@ subtest "check fetch update with unicode comment" => sub { }]'; my $res = $endpoint->run_test_request( - GET => '/servicerequestupdates.json?jurisdiction_id=rutland', + GET => '/servicerequestupdates.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -848,7 +848,7 @@ subtest "check fetch update with not responsible status" => sub { }]'; my $res = $endpoint->run_test_request( - GET => '/servicerequestupdates.json?jurisdiction_id=rutland', + GET => '/servicerequestupdates.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -868,7 +868,7 @@ subtest "check fetch update with not responsible status" => sub { subtest "check fetch service description" => sub { my $res = $endpoint->run_test_request( - GET => '/services.json?jurisdiction_id=rutland', + GET => '/services.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -899,7 +899,7 @@ subtest "check fetch service description" => sub { subtest "check fetch failing request" => sub { my $res = $endpoint->run_test_request( - GET => '/services/RC_09.json?jurisdiction_id=rutland', + GET => '/services/RC_09.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; @@ -917,7 +917,7 @@ subtest "check fetch failing request" => sub { subtest "check fetch service metadata" => sub { my $res = $endpoint->run_test_request( - GET => '/services/a096E000007pbxWQAQ.json?jurisdiction_id=rutland', + GET => '/services/a096E000007pbxWQAQ.json?jurisdiction_id=rutland_salesforce', ); my $sent = pop @sent; diff --git a/t/open311/endpoint/uk.t b/t/open311/endpoint/uk.t index 8cef9c3d5..0b6146dcd 100644 --- a/t/open311/endpoint/uk.t +++ b/t/open311/endpoint/uk.t @@ -18,7 +18,6 @@ test_multi(0, 'Open311::Endpoint::Integration::UK', 'Open311::Endpoint::Integration::UK::Kingston' => 'kingston_echo', 'Open311::Endpoint::Integration::UK::Lincolnshire' => 'lincolnshire_confirm', 'Open311::Endpoint::Integration::UK::NorthumberlandAlloy' => 'northumberland_alloy', - 'Open311::Endpoint::Integration::UK::Rutland' => 'rutland', 'Open311::Endpoint::Integration::UK::Shropshire' => 'shropshire_confirm', 'Open311::Endpoint::Integration::UK::Southwark' => 'southwark_confirm', 'Open311::Endpoint::Integration::UK::Surrey' => 'surrey_boomi', @@ -92,6 +91,11 @@ test_multi(0, 'Open311::Endpoint::Integration::UK::BANES', #'Open311::Endpoint::Integration::UK::Bristol::Passthrough' => 'www.banes.gov.uk', ); +test_multi(1, 'Open311::Endpoint::Integration::UK::Rutland', + 'Open311::Endpoint::Integration::UK::Rutland::SalesForce' => 'rutland_salesforce', +); + + done_testing; sub test_multi { From 44ed2b641aa4f6a3e8ad2f6bb5e0536fe286cc83 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Wed, 25 Feb 2026 10:44:41 +0000 Subject: [PATCH 02/11] [Rutland] Add Confirm integration Adds Confirm for Rutland and Confirm example yml file. Also changes SalesForce test to use the Salesforce module specifically and renames Rutland salesforce example file to accomodate Confirm example file. https://github.com/mysociety/societyworks/issues/5399 --- conf/council-rutland_confirm.yml-example | 18 ++++++++++++++++++ ... => council-rutland_salesforce.yml-example} | 0 .../Endpoint/Integration/UK/Rutland/Confirm.pm | 14 ++++++++++++++ .../{rutland.t => rutland_salesforce.t} | 4 ++-- t/open311/endpoint/uk.t | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 conf/council-rutland_confirm.yml-example rename conf/{council-rutland.yml-example => council-rutland_salesforce.yml-example} (100%) create mode 100644 perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm rename t/open311/endpoint/{rutland.t => rutland_salesforce.t} (99%) diff --git a/conf/council-rutland_confirm.yml-example b/conf/council-rutland_confirm.yml-example new file mode 100644 index 000000000..8e26877b6 --- /dev/null +++ b/conf/council-rutland_confirm.yml-example @@ -0,0 +1,18 @@ +"logfile": "" +"min_log_level": "" + +"endpoint_url": "" +"username": "" +"password": "" +"tenant_id": "" + +"cutoff_enquiry_date": "" +"enquiry_method_code": "" +"point_of_contact_code": "" +"server_timezone": "" + +"service_whitelist": {} + +"forward_status_mapping": {} + +"reverse_status_mapping": {} diff --git a/conf/council-rutland.yml-example b/conf/council-rutland_salesforce.yml-example similarity index 100% rename from conf/council-rutland.yml-example rename to conf/council-rutland_salesforce.yml-example diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm new file mode 100644 index 000000000..bdc65a65d --- /dev/null +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm @@ -0,0 +1,14 @@ +package Open311::Endpoint::Integration::UK::Rutland::Confirm; + +use Moo; +extends 'Open311::Endpoint::Integration::Confirm'; + +use Open311::Endpoint::Service::UKCouncil::Confirm; + +around BUILDARGS => sub { + my ($orig, $class, %args) = @_; + $args{jurisdiction_id} = 'rutland_confirm'; + return $class->$orig(%args); +}; + +1; diff --git a/t/open311/endpoint/rutland.t b/t/open311/endpoint/rutland_salesforce.t similarity index 99% rename from t/open311/endpoint/rutland.t rename to t/open311/endpoint/rutland_salesforce.t index 2ed37338a..5a64f8198 100644 --- a/t/open311/endpoint/rutland.t +++ b/t/open311/endpoint/rutland_salesforce.t @@ -46,10 +46,10 @@ use Data::Dumper; use JSON::MaybeXS; BEGIN { $ENV{TEST_MODE} = 1; } -use Open311::Endpoint::Integration::UK::Rutland; +use Open311::Endpoint::Integration::UK::Rutland::SalesForce; use Integrations::SalesForce::Rutland; -my $endpoint = Open311::Endpoint::Integration::UK::Rutland->new; +my $endpoint = Open311::Endpoint::Integration::UK::Rutland::SalesForce->new; my %responses = ( 'new_report' => '[{ "Id": "12345" }]', diff --git a/t/open311/endpoint/uk.t b/t/open311/endpoint/uk.t index 0b6146dcd..b2583cd48 100644 --- a/t/open311/endpoint/uk.t +++ b/t/open311/endpoint/uk.t @@ -93,6 +93,7 @@ test_multi(0, 'Open311::Endpoint::Integration::UK::BANES', test_multi(1, 'Open311::Endpoint::Integration::UK::Rutland', 'Open311::Endpoint::Integration::UK::Rutland::SalesForce' => 'rutland_salesforce', + 'Open311::Endpoint::Integration::UK::Rutland::Confirm' => 'rutland_confirm', ); From 7bd4d7fab190bfab5de71c6e507b4e7e065f3520 Mon Sep 17 00:00:00 2001 From: Matthew Somerville Date: Fri, 27 Feb 2026 17:54:25 +0000 Subject: [PATCH 03/11] [Confirm] Add GetCustomerLookups call. Make it easier to look up from the command line. --- perllib/Integrations/Confirm.pm | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/perllib/Integrations/Confirm.pm b/perllib/Integrations/Confirm.pm index 6e205d072..7e2692186 100644 --- a/perllib/Integrations/Confirm.pm +++ b/perllib/Integrations/Confirm.pm @@ -50,7 +50,7 @@ has ua => ( If the Confirm endpoint requires a particular EnquiryMethodCode for NewEnquiry requests, override this in the subclass. Valid values can be found by calling -the GetCustomerLookup method on the endpoint. +the GetCustomerLookups method on the endpoint. =cut @@ -64,7 +64,7 @@ has 'enquiry_method_code' => ( Similar to enquiry_method_code, if the Confirm endpoint requires a particular PointOfContactCode for NewEnquiry requests, override this in the subclass. -Valid values can be found by calling the GetCustomerLookup method on the endpoint. +Valid values can be found by calling the GetCustomerLookups method on the endpoint. =cut @@ -78,7 +78,7 @@ has 'point_of_contact_code' => ( Similar to enquiry_method_code/point_of_contact_code, if the Confirm endpoint requires a particular CustomerTypeCode for NewEnquiry requests, override this in the subclass. -Valid values can be found by calling the GetCustomerLookup method on the endpoint. +Valid values can be found by calling the GetCustomerLookups method on the endpoint. =cut @@ -874,6 +874,12 @@ sub GetEnquiries { return @enquiries; } +sub GetCustomerLookups { + my $self = shift; + my $lookups = $self->perform_request(\SOAP::Data->name('GetCustomerLookups')); + return $lookups; +} + sub GetEnquiryLookups { my $self = shift; From a46d446f4332bebde7168dde2800b509f78551ee Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Fri, 6 Mar 2026 13:38:33 +0000 Subject: [PATCH 04/11] [Rutland] Fetch completion photos by categorisation tag Adds an attribute to fetch for documents from Confirm of docTypeCode which is used to filter on photo documents to just the completed type. https://docs.google.com/document/d/1-R5LQy3M49t4YqoYBv_KLi4qzK5A4BX3MzZ097ow7jY/edit?tab=t.0#heading=h.6j01y3yqz03k --- .../Open311/Endpoint/Integration/Confirm.pm | 5 +- .../Integration/UK/Rutland/Confirm.pm | 14 +++++ t/open311/endpoint/rutland_confirm.t | 57 +++++++++++++++++++ t/open311/endpoint/rutland_confirm.yml | 11 ++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 t/open311/endpoint/rutland_confirm.t create mode 100644 t/open311/endpoint/rutland_confirm.yml diff --git a/perllib/Open311/Endpoint/Integration/Confirm.pm b/perllib/Open311/Endpoint/Integration/Confirm.pm index fbc8dcb53..715e6122f 100644 --- a/perllib/Open311/Endpoint/Integration/Confirm.pm +++ b/perllib/Open311/Endpoint/Integration/Confirm.pm @@ -632,7 +632,7 @@ sub get_service_request_updates { my $job_photos = @{ $integ->enquiry_update_job_photo_statuses }; my $defect_photos = @{ $integ->enquiry_update_defect_photo_statuses }; - my $documents = 'documents { url documentName documentDate }'; + my $documents = 'documents { url documentName documentDate docTypeCode}'; my $job_documents = $job_photos ? $documents : ""; my $defect_documents = $defect_photos ? $documents : ""; @@ -1834,7 +1834,8 @@ sub _parse_graphql_docs { return map { { URL => $_->{url}, Name => $_->{documentName}, - Date => $self->date_parser->parse_datetime($_->{documentDate}) + Date => $self->date_parser->parse_datetime($_->{documentDate}), + ClassificationCode => $_->{docTypeCode} || '', } } @$docs; } diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm index bdc65a65d..17709f006 100644 --- a/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm @@ -11,4 +11,18 @@ around BUILDARGS => sub { return $class->$orig(%args); }; +=head2 filter_photos_graphql + +Rutland want us to return photos with specific classification +tag. + +=cut + +around filter_photos_graphql => sub { + my ($orig, $self, @photos) = @_; + my @filtered = $self->$orig(@photos); + return grep { $_->{ClassificationCode} && $_->{ClassificationCode} eq 'DT20' } @filtered; +}; + + 1; diff --git a/t/open311/endpoint/rutland_confirm.t b/t/open311/endpoint/rutland_confirm.t new file mode 100644 index 000000000..1ce38a7c7 --- /dev/null +++ b/t/open311/endpoint/rutland_confirm.t @@ -0,0 +1,57 @@ +package Integrations::Confirm::Rutland::Dummy; +use Path::Tiny; +use Moo; +extends 'Integrations::Confirm'; +sub _build_config_file { path(__FILE__)->sibling("rutland_confirm.yml")->stringify } + +package Open311::Endpoint::Integration::UK::Rutland::Confirm::Dummy; +use Path::Tiny; +use Moo; +extends 'Open311::Endpoint::Integration::UK::Rutland::Confirm'; +around BUILDARGS => sub { + my ($orig, $class, %args) = @_; + $args{jurisdiction_id} = 'rutland_confirm'; + $args{config_file} = path(__FILE__)->sibling("rutland_confirm.yml")->stringify; + return $class->$orig(%args); +}; +has integration_class => (is => 'ro', default => 'Integrations::Confirm::Rutland::Dummy'); + +package main; + +use strict; +use warnings; + +use Test::More; + +BEGIN { $ENV{TEST_MODE} = 1; } + +my $endpoint = Open311::Endpoint::Integration::UK::Rutland::Confirm::Dummy->new; + +subtest "Only uses the photo with the correct classification tag" => sub { + my @photos = ( + { + URL => '1', + Name => '1.jpg', + Date => DateTime->now->subtract(days => 1), + ClassificationCode => 'DT10', + }, + { + URL => '2', + Name => '2.jpg', + Date => DateTime->now->subtract(days => 2), + ClassificationCode => 'DT20', + }, + { + URL => '3', + Name => '3.jpg', + Date => DateTime->now->subtract(days => 3), + }, + ); + my @filtered = $endpoint->filter_photos_graphql(@photos); + + is @filtered, 1; + is $filtered[0]->{URL}, 2; +}; + +done_testing; + diff --git a/t/open311/endpoint/rutland_confirm.yml b/t/open311/endpoint/rutland_confirm.yml new file mode 100644 index 000000000..61591f262 --- /dev/null +++ b/t/open311/endpoint/rutland_confirm.yml @@ -0,0 +1,11 @@ +endpoint_url: "https://test.example.com/graphql" +web_url: "https://test.example.com/" +username: "test_user" +password: "test_pass" +tenant_id: "test_tenant" +server_timezone: "Europe/London" +service_whitelist: {} +ignored_attributes: [] +ignored_attribute_options: [] +forward_status_mapping: {} +reverse_status_mapping: {} From 83b2ffd66023954fb0c0128d9df5234f9f338992 Mon Sep 17 00:00:00 2001 From: Matthew Somerville Date: Mon, 9 Mar 2026 14:09:18 +0000 Subject: [PATCH 05/11] Add unchanged update state option. --- perllib/Open311/Endpoint/Role/mySociety.pm | 2 ++ perllib/Open311/Endpoint/Service/Request/Update/mySociety.pm | 1 + 2 files changed, 3 insertions(+) diff --git a/perllib/Open311/Endpoint/Role/mySociety.pm b/perllib/Open311/Endpoint/Role/mySociety.pm index 016628224..9fb212cdc 100644 --- a/perllib/Open311/Endpoint/Role/mySociety.pm +++ b/perllib/Open311/Endpoint/Role/mySociety.pm @@ -326,6 +326,7 @@ sub learn_additional_types { my ($self, $schema) = @_; $schema->learn_type( 'tag:wiki.open311.org,GeoReport_v2:rx/status_extended', Open311::Endpoint::Schema->enum('//str', + 'unchanged', 'open', 'closed', 'fixed', @@ -346,6 +347,7 @@ sub learn_additional_types { ); $schema->learn_type( 'tag:wiki.open311.org,GeoReport_v2:rx/status_extended_upper', Open311::Endpoint::Schema->enum('//str', + 'UNCHANGED', 'OPEN', 'CLOSED', 'FIXED', diff --git a/perllib/Open311/Endpoint/Service/Request/Update/mySociety.pm b/perllib/Open311/Endpoint/Service/Request/Update/mySociety.pm index 388c5b7a9..6fc64c65f 100644 --- a/perllib/Open311/Endpoint/Service/Request/Update/mySociety.pm +++ b/perllib/Open311/Endpoint/Service/Request/Update/mySociety.pm @@ -8,6 +8,7 @@ extends 'Open311::Endpoint::Service::Request::Update'; has status => ( is => 'ro', isa => Enum[ + 'unchanged', # For when the update has no state 'open', 'closed', 'fixed', From 1fa19c8f1178249faa0ee88bf4dce6c4cff91ae1 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Fri, 13 Mar 2026 13:29:50 +0000 Subject: [PATCH 06/11] [Open311][Rutland] Pass through update text for 'FMS' status If an update has a status of FMS it is only for adding a comment on FMS and should not change the status. No other statuses should pass through text with their updates. https://github.com/mysociety/societyworks/issues/5453 --- .../Integration/UK/Rutland/Confirm.pm | 11 ++++++++++ t/open311/endpoint/rutland_confirm.t | 22 +++++++++++++++++++ t/open311/endpoint/rutland_confirm.yml | 4 +++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm index 17709f006..a8ef7482e 100644 --- a/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/Confirm.pm @@ -8,6 +8,7 @@ use Open311::Endpoint::Service::UKCouncil::Confirm; around BUILDARGS => sub { my ($orig, $class, %args) = @_; $args{jurisdiction_id} = 'rutland_confirm'; + $args{publish_service_update_text} = 1; return $class->$orig(%args); }; @@ -24,5 +25,15 @@ around filter_photos_graphql => sub { return grep { $_->{ClassificationCode} && $_->{ClassificationCode} eq 'DT20' } @filtered; }; +around _parse_enquiry_status_log => sub { + my ($orig, $self) = (shift, shift); + my $status_log = $_[0]; + + unless ($status_log->{EnquiryStatusCode} eq 'FMS') { + $status_log->{StatusLogNotes} = ''; + }; + + $self->$orig(@_); +}; 1; diff --git a/t/open311/endpoint/rutland_confirm.t b/t/open311/endpoint/rutland_confirm.t index 1ce38a7c7..51ba7a79a 100644 --- a/t/open311/endpoint/rutland_confirm.t +++ b/t/open311/endpoint/rutland_confirm.t @@ -22,9 +22,18 @@ use strict; use warnings; use Test::More; +use Test::LongString; +use Test::MockModule; BEGIN { $ENV{TEST_MODE} = 1; } +my $open311 = Test::MockModule->new('Integrations::Confirm'); +$open311->mock(perform_request => sub { + return { OperationResponse => { GetEnquiryStatusChangesResponse => { UpdatedEnquiry => [ + { EnquiryNumber => 2020, EnquiryStatusLog => [ { EnquiryLogNumber => 5, StatusLogNotes => 'Private status log notes', LogEffectiveTime => '2026-01-23T12:00:00Z', LoggedTime => '2026-01-23T12:00:00Z', EnquiryStatusCode => 'AFMS' }, { EnquiryLogNumber => 6, StatusLogNotes => 'Private status log notes', LogEffectiveTime => '2026-01-23T12:00:00Z', LoggedTime => '2026-01-23T12:00:00Z', EnquiryStatusCode => 'FMSA' }, { EnquiryLogNumber => 7, StatusLogNotes => 'Public status log notes', LogEffectiveTime => '2026-01-23T12:00:00Z', LoggedTime => '2026-01-23T12:00:00Z', EnquiryStatusCode => 'FMS' } ] }, + ] } } }; +}); + my $endpoint = Open311::Endpoint::Integration::UK::Rutland::Confirm::Dummy->new; subtest "Only uses the photo with the correct classification tag" => sub { @@ -53,5 +62,18 @@ subtest "Only uses the photo with the correct classification tag" => sub { is $filtered[0]->{URL}, 2; }; +subtest 'Only pass on log notes for updates for "FMS" status' => sub { + my $res = $endpoint->run_test_request( + GET => '/servicerequestupdates.xml?start_date=2018-01-01T00:00:00Z&end_date=2018-02-01T00:00:00Z', + ); + ok $res->is_success, 'valid request' or diag $res->content; + contains_string $res->content, '2020_5'; + contains_string $res->content, '2020_6'; + contains_string $res->content, '2020_7'; + lacks_string $res->content, 'Private status log notes'; + contains_string $res->content, 'Public status log notes'; + contains_string $res->content, 'unchanged'; +}; + done_testing; diff --git a/t/open311/endpoint/rutland_confirm.yml b/t/open311/endpoint/rutland_confirm.yml index 61591f262..c536fc71b 100644 --- a/t/open311/endpoint/rutland_confirm.yml +++ b/t/open311/endpoint/rutland_confirm.yml @@ -8,4 +8,6 @@ service_whitelist: {} ignored_attributes: [] ignored_attribute_options: [] forward_status_mapping: {} -reverse_status_mapping: {} +reverse_status_mapping: { + FMS: unchanged + } From 3f5b12515a0e9107ce2796dd1e3414c3a8f8be34 Mon Sep 17 00:00:00 2001 From: Matthew Somerville Date: Fri, 20 Mar 2026 10:37:33 +0000 Subject: [PATCH 07/11] [Confirm] Let multi instances also fetch photos. --- perllib/Open311/Endpoint/Role/Photos.pm | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/perllib/Open311/Endpoint/Role/Photos.pm b/perllib/Open311/Endpoint/Role/Photos.pm index aecec1154..48c630b09 100644 --- a/perllib/Open311/Endpoint/Role/Photos.pm +++ b/perllib/Open311/Endpoint/Role/Photos.pm @@ -25,11 +25,25 @@ around dispatch_request => sub { ); }; +# Handle being given a multi's jurisdiction_id directly sub get_photo { my ($self, $args) = @_; - $self->_call('get_photo', $args->{jurisdiction_id}, $args) - or [ 400, [ 'Content-type', 'text/plain' ], [ 'Bad request' ] ]; + my $jurisdiction_id = $args->{jurisdiction_id}; + + foreach ($self->plugins) { + if ($_->jurisdiction_id eq $jurisdiction_id) { + return $_->get_photo($args); + } + if ($_->isa('Open311::Endpoint::Integration::Multi')) { + foreach ($_->plugins) { + if ($_->jurisdiction_id eq $jurisdiction_id) { + return $_->get_photo($args); + } + } + } + } + + [ 400, [ 'Content-type', 'text/plain' ], [ 'Bad request' ] ]; } - 1; From 8787de82f96325c201dcce8e718f2b65b90138b6 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Fri, 13 Mar 2026 17:22:05 +0000 Subject: [PATCH 08/11] [Rutland] If backend not returning return empty services No dev backend for Salesforce prevents Confirm from populating services as generally a safeguard to prevent empty categories from a cobrand. Conversation https://mysociety.slack.com/archives/C01TK8P1K8T/p1773421827901549?thread_ts=1773421151.044429&cid=C01TK8P1K8T --- perllib/Integrations/SalesForce/Rutland.pm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/perllib/Integrations/SalesForce/Rutland.pm b/perllib/Integrations/SalesForce/Rutland.pm index 71097ff2b..ef5070ce0 100644 --- a/perllib/Integrations/SalesForce/Rutland.pm +++ b/perllib/Integrations/SalesForce/Rutland.pm @@ -4,6 +4,7 @@ use Moo; extends 'Integrations::SalesForce::Base'; use JSON::MaybeXS; +use Try::Tiny; has 'requests_endpoint' => ( is => 'ro', @@ -118,7 +119,12 @@ sub get_services { $self->logger->debug("No memcached entry found for $key. Fetching services from Salesforce"); $services = []; - my $response = $self->get($self->services_endpoint . '?summary'); + my $response; + try { + $response = $self->get($self->services_endpoint . '?summary'); + } catch { + return (); + }; for my $service (@{ $response->{CategoryInformation} }) { push @$services, $service; } From ee133dfede9196208d337d7cc9f9b0b7e54fc7d3 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Thu, 26 Mar 2026 11:44:48 +0000 Subject: [PATCH 09/11] [Rutland][SalesForce] Adds category whitelisting We need to filter out categories recieved from Salesforce which are duplicated by the new Confirm integration. https://github.com/mysociety/societyworks/issues/5400 --- .../Integration/UK/Rutland/SalesForce.pm | 8 +++++ t/open311/endpoint/rutland_salesforce.t | 35 ++++++++++++------- t/open311/endpoint/rutland_salesforce.yml | 3 ++ 3 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 t/open311/endpoint/rutland_salesforce.yml diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm index 115740686..d67eb656c 100644 --- a/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm @@ -20,6 +20,12 @@ has jurisdiction_id => ( default => 'rutland_salesforce', ); +has 'whitelist' => ( + is => 'ro', + is => 'lazy', + default => sub { shift->get_integration->config->{whitelist} || {} } +); + sub reverse_status_mapping { my ($self, $status) = @_; @@ -196,6 +202,7 @@ sub services { my ($self, $args) = @_; my @services = $self->get_integration->get_services($args); + @services = grep { $self->whitelist->{ $_->{name} } || $_->{hasChildren} eq 'true' } @services; my %service_lookup = map { $_->{serviceid} => $_ } @services; @@ -230,6 +237,7 @@ sub service { my $meta = $self->get_integration->get_service($id, $args); my @services = $self->get_integration->get_services($args); + @services = grep { $self->whitelist->{ $_->{name} } || $_->{hasChildren} eq 'true' } @services; my %service_lookup = map { $_->{serviceid} => $_ } @services; my $srv = $service_lookup{$id}; diff --git a/t/open311/endpoint/rutland_salesforce.t b/t/open311/endpoint/rutland_salesforce.t index 5a64f8198..25bc5c95e 100644 --- a/t/open311/endpoint/rutland_salesforce.t +++ b/t/open311/endpoint/rutland_salesforce.t @@ -30,6 +30,25 @@ has is_success => ( default => 1 ); +package Integrations::SalesForce::Rutland::Dummy; +use Path::Tiny; +use Moo; +extends 'Integrations::SalesForce::Rutland'; +sub _build_config_file { path(__FILE__)->sibling("rutland_salesforce.yml")->stringify } + +package Open311::Endpoint::Integration::UK::Rutland::SalesForce::Dummy; +use Path::Tiny; +use Moo; +extends 'Open311::Endpoint::Integration::UK::Rutland::SalesForce'; +around BUILDARGS => sub { + my ($orig, $class, %args) = @_; + $args{jurisdiction_id} = 'rutland_salesforce'; + $args{config_file} = path(__FILE__)->sibling("rutland_salesforce.yml")->stringify; + return $class->$orig(%args); +}; +has integration_class => (is => 'ro', default => 'Integrations::SalesForce::Rutland::Dummy'); + + package main; use strict; use warnings; @@ -44,12 +63,13 @@ use Test::MockTime ':all'; use Open311::Endpoint; use Data::Dumper; use JSON::MaybeXS; +use Path::Tiny; BEGIN { $ENV{TEST_MODE} = 1; } -use Open311::Endpoint::Integration::UK::Rutland::SalesForce; -use Integrations::SalesForce::Rutland; +use Open311::Endpoint::Integration::UK::Rutland::SalesForce::Dummy; +use Integrations::SalesForce::Rutland::Dummy; -my $endpoint = Open311::Endpoint::Integration::UK::Rutland::SalesForce->new; +my $endpoint = Open311::Endpoint::Integration::UK::Rutland::SalesForce::Dummy->new; my %responses = ( 'new_report' => '[{ "Id": "12345" }]', @@ -884,15 +904,6 @@ subtest "check fetch service description" => sub { type => "realtime", keywords => "", group => "Street Furniture" - }, - { - metadata => "true", - description => "Phasing/timing issues", - group => "Traffic Lights - Permanent", - service_code => "a012500000JJ0neAAD", - type => "realtime", - service_name => "Phasing/timing issues", - keywords => "" } ], 'correct json returned' or diag $res->content; }; diff --git a/t/open311/endpoint/rutland_salesforce.yml b/t/open311/endpoint/rutland_salesforce.yml new file mode 100644 index 000000000..084fce70c --- /dev/null +++ b/t/open311/endpoint/rutland_salesforce.yml @@ -0,0 +1,3 @@ +whitelist: + Fly Tipping: 1 + From 172861f6f3a7ed5c3c943d7e629b693188694cd7 Mon Sep 17 00:00:00 2001 From: Moray Jones Date: Thu, 26 Mar 2026 18:09:24 +0000 Subject: [PATCH 10/11] [Rutland] Make SalesForce extra data into notice So that the new Confirm categories are simpatico with the Salesforce categories, we are changing the extra information for Salesforce categories to appear as a notice rather than be in the category listings https://github.com/mysociety/societyworks/issues/5400 --- .../Integration/UK/Rutland/SalesForce.pm | 23 ++++++++--------- t/open311/endpoint/rutland_salesforce.t | 25 ++++++------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm index d67eb656c..cf57d3ca2 100644 --- a/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm +++ b/perllib/Open311/Endpoint/Integration/UK/Rutland/SalesForce.pm @@ -280,23 +280,22 @@ sub service { } my %options = ( + code => 'notice', required => 0, variable => 0, datatype => 'string', - automated => 'server_set', ); - push @{ $service->attributes }, Open311::Endpoint::Service::Attribute->new( - code => 'hint', - description => $hint, - %options, - ); - - push @{ $service->attributes }, Open311::Endpoint::Service::Attribute->new( - code => 'group_hint', - description => $group_hint, - %options, - ); + if ($hint || $group_hint) { + my $description = $group_hint ? '

' . $group_hint . '

' : ''; + $description .= $hint ? '

' . $hint . '

' : ''; + if ($description) { + push @{ $service->attributes }, Open311::Endpoint::Service::Attribute->new( + description => $description, + %options, + ); + }; + }; return $service; } diff --git a/t/open311/endpoint/rutland_salesforce.t b/t/open311/endpoint/rutland_salesforce.t index 25bc5c95e..d8b2cf4f5 100644 --- a/t/open311/endpoint/rutland_salesforce.t +++ b/t/open311/endpoint/rutland_salesforce.t @@ -989,24 +989,13 @@ subtest "check fetch service metadata" => sub { description => "Additional Information", }, { - variable => 'false', - code => "hint", - datatype => "string", - required => 'false', - datatype_description => '', - order => 6, - description => "This is the category HTML hint", - automated => 'server_set', - }, - { - variable => 'false', - code => "group_hint", - datatype => "string", - required => 'false', - datatype_description => '', - order => 7, - description => "This is the group HTML hint", - automated => 'server_set', + "variable" => "false", + "required" => "false", + "order" => 6, + "code" => "notice", + "description" => "

This is the group HTML hint

This is the category HTML hint

", + "datatype" => "string", + "datatype_description" => "", } ] }, 'correct json returned'; From fdef86c3238ab7a272c9fc463221e572813dbe5f Mon Sep 17 00:00:00 2001 From: Nik Gupta Date: Thu, 26 Mar 2026 10:41:06 +0000 Subject: [PATCH 11/11] [Open311] Enable multiple media_url items in get service request updates. Continues passing as string if one or empty array presented, otherwise will pass as array. --- perllib/Open311/Endpoint.pm | 9 +++++++-- perllib/Open311/Endpoint/Role/mySociety.pm | 13 +++++++++---- t/open311/endpoint/confirm_photos.t | 6 +++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/perllib/Open311/Endpoint.pm b/perllib/Open311/Endpoint.pm index f3141b5f2..9d7ac41e4 100644 --- a/perllib/Open311/Endpoint.pm +++ b/perllib/Open311/Endpoint.pm @@ -824,8 +824,13 @@ sub format_service_requests { ), ( map { - my $value = $request->$_->[0]; - $_ => $value || ''; + my $value; + if (scalar @{ $request->$_ } <= 1) { + $value = $request->$_->[0] || ''; + } else { + $value = $request->@{$_}; + } + $_ => $value; } qw/ media_url diff --git a/perllib/Open311/Endpoint/Role/mySociety.pm b/perllib/Open311/Endpoint/Role/mySociety.pm index 9fb212cdc..988697bd4 100644 --- a/perllib/Open311/Endpoint/Role/mySociety.pm +++ b/perllib/Open311/Endpoint/Role/mySociety.pm @@ -286,8 +286,13 @@ sub format_updates { ), ( map { - my $value = $update->$_->[0]; - $_ => $value || ''; + my $value; + if (scalar @{ $update->$_ } <= 1) { + $value = $update->$_->[0] || ''; + } else { + $value = $update->@{$_}; + } + $_ => $value; } qw/ media_url @@ -375,7 +380,7 @@ sub learn_additional_types { status => '/open311/status_extended', updated_datetime => '/open311/datetime', description => '//str', - media_url => '//str', + media_url => { type => '//any', of => [ { type => '//str' }, { type => '//arr', contents => '//str' } ] }, }, optional => { external_status_code => '//str', @@ -403,7 +408,7 @@ sub learn_additional_types { zipcode => '//str', lat => '//num', long => '//num', - media_url => '//str', + media_url => { type => '//any', of => [ { type => '//str' }, { type => '//arr', contents => '//str' } ] }, }, optional => { title => '//str', diff --git a/t/open311/endpoint/confirm_photos.t b/t/open311/endpoint/confirm_photos.t index 85358e3b5..eafafc84a 100644 --- a/t/open311/endpoint/confirm_photos.t +++ b/t/open311/endpoint/confirm_photos.t @@ -96,7 +96,11 @@ subtest "fetching of job photos for enquiry update" => sub { GET => '/servicerequestupdates.xml?start_date=2025-01-01T00:00:00Z&end_date=2025-01-01T01:00:00Z', ); ok $res->is_success, 'valid request' or diag $res->content; - contains_string $res->content, 'http://example.com/photos?jurisdiction_id=confirm_dummy_photos&job=432&photo=1'; + contains_string $res->content, + ' + http://example.com/photos?jurisdiction_id=confirm_dummy_photos&job=432&photo=1 + http://example.com/photos?jurisdiction_id=confirm_dummy_photos&job=432&photo=2 + '; $lwp->mock(request => \&empty_json); $integration->mock(perform_request => \&empty_json);