From 0bd55a88555443d130087bc39f83fa4297a304eb Mon Sep 17 00:00:00 2001 From: Steve Wirt Date: Fri, 2 Feb 2024 19:39:48 -0500 Subject: [PATCH] VACMS-15559 Migration of service location data to paragraphs (#16911) * Add post_update hook to move facility service location data * Add placeholder methods and mapping * Adding more notes * VACMS-15559 FIXUP refactor into class. * VACMS-15559 Refactory functions to scipt library. * Add new map to value function to script-library. * VACMS-15559 Remove conflation of new revision and new node. * VACMS-15559 Add all migrated fields to migration. * VACMS-15559 Programatically shut off queue checks and processing. * VACMS-15559 Move deploy function to script. * VACMS-15559 Update audit, make sortable, add editor updated. * VACMS-15559 Cleaned up function names and some comments. * VACMS-15559 Fix incorrect office_visits parameter. * VACMS-15559 Make service location audit headers sticky. * VACMS-15559 Fix forward revision. * VACMS-15559 Fix the timing on grabing the lastes revision. * VACMS-15559 Fix recent changes View to sort by revision id, not time. * VACMS-15559 Carry forward draft revision log message. --- ...ews.view.right_sidebar_latest_revision.yml | 16 +- .../views.view.service_locations_audit.yml | 130 ++++++++-- .../src/ServiceLocationMigration.php | 226 ++++++++++++++++++ .../custom/va_gov_vamc/va_gov_vamc.deploy.php | 6 +- .../custom/va_gov_vamc/va_gov_vamc.install | 73 +----- ...ervice-location-from-node-to-paragraph.php | 52 ++++ 6 files changed, 406 insertions(+), 97 deletions(-) create mode 100644 docroot/modules/custom/va_gov_vamc/src/ServiceLocationMigration.php create mode 100644 scripts/content/VACMS-15559-migrate-service-location-from-node-to-paragraph.php diff --git a/config/sync/views.view.right_sidebar_latest_revision.yml b/config/sync/views.view.right_sidebar_latest_revision.yml index 337bc150cb..1709c3ce4e 100644 --- a/config/sync/views.view.right_sidebar_latest_revision.yml +++ b/config/sync/views.view.right_sidebar_latest_revision.yml @@ -312,22 +312,21 @@ display: options: { } empty: { } sorts: - revision_timestamp: - id: revision_timestamp - table: node_revision - field: revision_timestamp + vid: + id: vid + table: node_field_revision + field: vid relationship: none group_type: group admin_label: '' entity_type: node - entity_field: revision_timestamp - plugin_id: date + entity_field: vid + plugin_id: standard order: DESC expose: label: '' - field_identifier: revision_timestamp + field_identifier: '' exposed: false - granularity: second arguments: nid: id: nid @@ -348,7 +347,6 @@ display: title: '' default_argument_type: node default_argument_options: { } - default_argument_skip_url: false summary_options: base_path: '' count: true diff --git a/config/sync/views.view.service_locations_audit.yml b/config/sync/views.view.service_locations_audit.yml index 5d570d9b8f..d9479393c6 100644 --- a/config/sync/views.view.service_locations_audit.yml +++ b/config/sync/views.view.service_locations_audit.yml @@ -6,6 +6,7 @@ dependencies: - field.storage.node.field_administration - field.storage.node.field_hservice_appt_intro_select - field.storage.node.field_hservice_appt_leadin + - field.storage.node.field_last_saved_by_an_editor - field.storage.node.field_online_scheduling_availabl - field.storage.node.field_phone_numbers_paragraph - field.storage.node.field_walk_ins_accepted @@ -1182,16 +1183,93 @@ display: multi_type: separator separator: ', ' field_api_classes: false + field_last_saved_by_an_editor: + id: field_last_saved_by_an_editor + table: node__field_last_saved_by_an_editor + field: field_last_saved_by_an_editor + relationship: none + group_type: group + admin_label: '' + plugin_id: field + label: 'Last Saved by an Editor' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: timestamp + settings: + date_format: medium + custom_date_format: '' + timezone: '' + tooltip: + date_format: long + custom_date_format: '' + time_diff: + enabled: false + future_format: '@interval hence' + past_format: '@interval ago' + granularity: 2 + refresh: 60 + description: '' + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false pager: - type: mini + type: full options: offset: 0 - items_per_page: 10 + items_per_page: 100 total_pages: null id: 0 tags: next: ›› previous: ‹‹ + first: '« First' + last: 'Last »' expose: items_per_page: false items_per_page_label: 'Items per page' @@ -1200,6 +1278,8 @@ display: items_per_page_options_all_label: '- All -' offset: false offset_label: Offset + quantity: 9 + pagination_heading_level: h4 exposed_form: type: basic options: @@ -1221,15 +1301,15 @@ display: options: { } empty: { } sorts: - created: - id: created + changed: + id: changed table: node_field_data - field: created + field: changed relationship: none group_type: group admin_label: '' entity_type: node - entity_field: created + entity_field: changed plugin_id: date order: DESC expose: @@ -1514,6 +1594,7 @@ display: field_other_phone_numbers: field_other_phone_numbers field_use_facility_phone_number: field_use_facility_phone_number nid: nid + field_last_saved_by_an_editor: field_last_saved_by_an_editor default: '-1' info: title: @@ -1524,14 +1605,14 @@ display: empty_column: false responsive: '' type: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' moderation_state_1: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' @@ -1545,7 +1626,7 @@ display: empty_column: false responsive: '' field_administration: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' @@ -1559,56 +1640,56 @@ display: empty_column: false responsive: '' field_hservice_appt_intro_select: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_appt_intro_text_type: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_hservice_appt_leadin: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_appt_intro_text_custom: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_walk_ins_accepted: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_office_visits: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_online_scheduling_availabl: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' field_online_scheduling_avail: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' @@ -1625,21 +1706,28 @@ display: empty_column: false responsive: '' field_use_facility_phone_number: - sortable: false + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' nid: - sortable: false + sortable: true + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + field_last_saved_by_an_editor: + sortable: true default_sort_order: asc align: '' separator: '' empty_column: false responsive: '' override: true - sticky: false + sticky: true summary: '' empty_table: false caption: 'Service location results' @@ -1690,6 +1778,7 @@ display: - 'config:field.storage.node.field_administration' - 'config:field.storage.node.field_hservice_appt_intro_select' - 'config:field.storage.node.field_hservice_appt_leadin' + - 'config:field.storage.node.field_last_saved_by_an_editor' - 'config:field.storage.node.field_online_scheduling_availabl' - 'config:field.storage.node.field_phone_numbers_paragraph' - 'config:field.storage.node.field_walk_ins_accepted' @@ -1737,6 +1826,7 @@ display: - 'config:field.storage.node.field_administration' - 'config:field.storage.node.field_hservice_appt_intro_select' - 'config:field.storage.node.field_hservice_appt_leadin' + - 'config:field.storage.node.field_last_saved_by_an_editor' - 'config:field.storage.node.field_online_scheduling_availabl' - 'config:field.storage.node.field_phone_numbers_paragraph' - 'config:field.storage.node.field_walk_ins_accepted' diff --git a/docroot/modules/custom/va_gov_vamc/src/ServiceLocationMigration.php b/docroot/modules/custom/va_gov_vamc/src/ServiceLocationMigration.php new file mode 100644 index 0000000000..e459199cc8 --- /dev/null +++ b/docroot/modules/custom/va_gov_vamc/src/ServiceLocationMigration.php @@ -0,0 +1,226 @@ +loadMultiple(array_values($nids)); + foreach ($facility_services as $nid => $facility_service_node) { + /** @var \Drupal\node\NodeInterface $facility_service_node */ + $this->facilityService = $facility_service_node; + // Gather existing service locations. + $service_locations = $facility_service_node->get('field_service_location')->referencedEntities(); + if (empty($service_locations)) { + // There are no service locations, but we still need to put the old node + // data some place, so we need a new service location. + $new_service_location = Paragraph::create(['type' => 'field_service_location']); + $facility_service_node->field_service_location->appendItem($new_service_location); + $service_locations = [$new_service_location]; + $this->facilityService->field_service_location[] = $new_service_location; + $sandbox['service_locations_created_count'] = (isset($sandbox['service_locations_created_count'])) ? ++$sandbox['service_locations_created_count'] : 1; + } + else { + $sandbox['service_locations_updated_count'] = (isset($sandbox['service_locations_updated_count'])) ? ++$sandbox['service_locations_updated_count'] : 1; + } + + $this->migrateServicesLocationsFromFacility($service_locations); + $message = 'Automated move of Facility Health Service data into Service Locations.'; + // Must grab this before save, because after save it will always be true. + $is_latest_revision = $this->facilityService->isLatestRevision(); + if (!$is_latest_revision) { + // The Facility service we have is the default revision, but if it is + // not the latest, there is a forward draft revision we need to update + // too. Grab it now before saving, or we'll grab the one we just saved. + $forward_revision = get_node_at_latest_revision($nid); + } + save_node_revision($this->facilityService, $message, TRUE); + + if (!$is_latest_revision) { + $this->facilityService = $forward_revision; + $this->migrateServicesLocationsFromFacility($service_locations); + // Append new log message to previous log message. + $original_message = $this->facilityService->getRevisionLogMessage(); + $message .= "- Draft revision carried forward."; + $message = "$original_message - $message"; + save_node_revision($this->facilityService, $message, TRUE); + $sandbox['forward_revisions_count'] = (isset($sandbox['forward_revisions_count'])) ? ++$sandbox['forward_revisions_count'] : 1; + } + + unset($sandbox['items_to_process'][_va_gov_stringifynid($nid)]); + $processed_nids .= $nid . ', '; + $sandbox['current']++; + } + // Log the processed nodes. + \Drupal::logger('va_gov_vamc') + ->log(LogLevel::INFO, 'Facility Health Service nodes %current nodes saved to migrate some data into paragraphs. %forward_revisions were also updated. Nodes processed: %nids', [ + '%current' => $sandbox['current'], + '%nids' => $processed_nids, + '%forward_revisions' => $sandbox['forward_revisions_count'] ?? 0, + ]); + } + + /** + * Migrate into each of the service location fields. + * + * @param array $service_locations + * An array of service locations to migrate into. + */ + protected function migrateServicesLocationsFromFacility(array $service_locations): void { + foreach ($service_locations as $service_location) { + /** @var \Drupal\paragraphs\ParagraphInterface $service_location */ + $this->serviceLocation = $service_location; + $this->migrateAppointmentIntroType(); + $this->migrateAppointmentIntroText(); + $this->migrateAppointmentUseFacilityPhone(); + $this->migrateAppointmentPhoneNumbers(); + $this->migrateScheduleOnline(); + $this->migrateWalkinsAccepted(); + $this->serviceLocation->setNewRevision(TRUE); + } + } + + /** + * Move appointment type from the service node to the service location. + */ + protected function migrateAppointmentIntroType(): void { + // Moving from: field_hservice_appt_intro_select. + // Moving to: paragraph.service_location.field_appt_intro_text_type. + $intro_type = $this->facilityService->get('field_hservice_appt_intro_select')->value; + $type_map = [ + 'default_intro_text' => 'use_default_text', + 'custom_intro_text' => 'customize_text', + 'no_intro_text' => 'remove_text', + 'default' => 'use_default_text', + ]; + $new_value = script_libary_map_to_value($intro_type, $type_map); + $this->serviceLocation->set('field_appt_intro_text_type', $new_value); + } + + /** + * Move the appointment text from the service node to the service location. + */ + protected function migrateAppointmentIntroText(): void { + // Moving from: field_hservice_appt_leadin. + // Moving to: paragraph.service_location.field_appointment_intro_text. + $intro_text = $this->facilityService->get('field_hservice_appt_leadin')->value; + $this->serviceLocation->set('field_appt_intro_text_custom', $intro_text); + } + + /** + * Populates the service location based on the presence of apt phone. + */ + protected function migrateAppointmentUseFacilityPhone(): void { + // Based on the presence of data in field_phone_numbers_paragraph. + // To field_use_facility_phone_number. + $has_no_apt_phone = $this->facilityService->get('field_phone_numbers_paragraph')->isEmpty(); + // The assumption is that if there is no existing phone, then it should + // be the main number by default. There is a risk in that they wanted + // the main number and to provide a second, but there is nothing to + // base that decision on to make it cleaner. + $use_main_facility_phone = ($has_no_apt_phone) ? 1 : 0; + $this->serviceLocation->set('field_use_facility_phone_number', $use_main_facility_phone); + } + + /** + * Move the apt phone numbers properties. + */ + protected function migrateAppointmentPhoneNumbers(): void { + // Moving from: field_phone_numbers_paragraph. + // Moving to: field_other_phone_numbers (paragraphs). + $phone_paragraphs = $this->facilityService->get('field_phone_numbers_paragraph')->referencedEntities(); + + foreach ($phone_paragraphs as $phone) { + // Add each one to the service location. + // Need to move Type, Phone number, Extension number, and Label. + $data = [ + 'type' => 'phone_number', + 'field_phone_number_type' => $phone->get('field_phone_number_type')->value, + 'field_phone_number' => $phone->get('field_phone_number')->value, + 'field_phone_extension' => $phone->get('field_phone_extension')->value, + 'field_phone_label' => $phone->get('field_phone_label')->value, + ]; + $new_phone = Paragraph::create($data); + $this->serviceLocation->field_other_phone_numbers[] = $new_phone; + } + } + + /** + * Moves the online scheduling from Service to Service location. + */ + protected function migrateScheduleOnline(): void { + // Moving from: field_online_scheduling_availabl. + // Moving to: field_online_scheduling_avail. + $schedule_online_map = [ + // Schedule online => service location schedule online. + // 'Yes' => 'Yes'. + '1' => 'yes', + // 'No' => 'No'. + '0' => 'no', + // This is the do no harm, option defaulting to most restrictive. + // 'unspecified' => 'No'. + 'not_applicable' => 'no', + 'default' => 'no', + ]; + $lookup = $this->facilityService->get('field_online_scheduling_availabl')->value; + $new_value = script_libary_map_to_value($lookup, $schedule_online_map); + $this->serviceLocation->set('field_online_scheduling_avail', $new_value); + } + + /** + * Move and map the walkins accepted to office visits. + */ + protected function migrateWalkinsAccepted(): void { + // Moving from: node field_walk_ins_accepted. + // Moving to: field_office_visits. + $walkins_accepted_map = [ + // Walkins accepted => office visits. + // 'No' => 'yes by appointment only'. + '0' => 'yes_appointment_only', + // 'Yes' => 'Yes, with or without an appointment'. + '1' => 'yes_with_or_without_appointment', + // This is the do no harm, option defaulting to most restrictive. + // 'unspecified' => 'yes by appointment only'. + 'not_applicable' => 'yes_appointment_only', + 'default' => 'yes_appointment_only', + ]; + $lookup = $this->facilityService->get('field_walk_ins_accepted')->value; + $new_value = script_libary_map_to_value($lookup, $walkins_accepted_map); + $this->serviceLocation->set('field_office_visits', $new_value); + } + +} diff --git a/docroot/modules/custom/va_gov_vamc/va_gov_vamc.deploy.php b/docroot/modules/custom/va_gov_vamc/va_gov_vamc.deploy.php index 817c241fa7..8136f3747a 100644 --- a/docroot/modules/custom/va_gov_vamc/va_gov_vamc.deploy.php +++ b/docroot/modules/custom/va_gov_vamc/va_gov_vamc.deploy.php @@ -5,13 +5,15 @@ * Post deploy file VA Gov VAMC. */ +require_once __DIR__ . '/../../../../scripts/content/script-library.php'; + /** * Build top VA Police page and menu items for each system. */ function va_gov_vamc_deploy_build_va_police_9001(&$sandbox) { \Drupal::moduleHandler()->loadInclude('va_gov_vamc', 'install'); $build_bundle = 'vamc_system_va_police'; - _va_gov_vamc_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); + script_library_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); _va_gov_vamc_create_system_content_pages($sandbox, $build_bundle, 'draft'); - return _va_gov_vamc_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); + return script_library_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); } diff --git a/docroot/modules/custom/va_gov_vamc/va_gov_vamc.install b/docroot/modules/custom/va_gov_vamc/va_gov_vamc.install index e95d881a0e..ccac7cc239 100644 --- a/docroot/modules/custom/va_gov_vamc/va_gov_vamc.install +++ b/docroot/modules/custom/va_gov_vamc/va_gov_vamc.install @@ -6,71 +6,12 @@ */ use Drupal\codit_menu_tools\MenuInsert; -use Drupal\Component\Render\FormattableMarkup; -use Drupal\Core\Utility\UpdateException; use Drupal\field\Entity\FieldConfig; use Drupal\node\Entity\Node; use Drupal\va_gov_lovell\LovellOps; use Psr\Log\LogLevel; -/** - * Initializes the basic sandbox values. - * - * @param array $sandbox - * Standard drupal $sandbox var to keep state in hook_update_N. - * @param string $counter_callback - * A function name to call to get the items to process. Must return an array. - * @param array $callback_args - * A flat array of arguments to pass to the counter_callback. - * - * @throws Drupal\Core\Utility\UpdateException - * If the counter callback can not be found. - */ -function _va_gov_vamc_sandbox_init(array &$sandbox, $counter_callback, array $callback_args = []) { - if (empty($sandbox['total'])) { - // Sandbox has not been initiated. - if (is_callable($counter_callback)) { - $sandbox['items_to_process'] = call_user_func_array($counter_callback, $callback_args); - $sandbox['total'] = count($sandbox['items_to_process']); - $sandbox['current'] = 0; - } - else { - // Something went wrong could not use callback. Throw exception. - throw new UpdateException( - "Counter callback {$counter_callback} provided in _va_gov_vamc_sandbox_init() is not callable. Can not proceed." - ); - } - } -} - -/** - * Updates the counts and log if complete. - * - * @param array $sandbox - * Hook_update_n sandbox for keeping state. - * @param string $completed_message - * Message to log when completed. Can use '@completed' and '@total' as tokens. - * - * @return string - * String to be used as update hook messages. - */ -function _va_gov_vamc_sandbox_complete(array &$sandbox, $completed_message) { - // Determine when to stop batching. - $sandbox['current'] = ($sandbox['total'] - count($sandbox['items_to_process'])); - $sandbox['#finished'] = (empty($sandbox['total'])) ? 1 : ($sandbox['current'] / $sandbox['total']); - $vars = [ - '@completed' => $sandbox['current'], - '@total' => $sandbox['total'], - ]; - $message = t('Processing... @completed/@total.', $vars) . PHP_EOL; - // Log the all finished notice. - if ($sandbox['#finished'] === 1) { - Drupal::logger('va_gov_vamc')->log(LogLevel::INFO, $completed_message, $vars); - $logged_message = new FormattableMarkup($completed_message, $vars); - $message = t('Process completed:') . " {$logged_message}" . PHP_EOL; - } - return $message; -} +require_once __DIR__ . '/../../../../scripts/content/script-library.php'; /** * Calculates an array of system nids that need to have a top task page created. @@ -257,9 +198,9 @@ function va_gov_vamc_update_9003() { */ function va_gov_vamc_update_9004(&$sandbox) { $build_bundle = 'vamc_system_billing_insurance'; - _va_gov_vamc_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); + script_library_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); _va_gov_vamc_create_system_content_pages($sandbox, $build_bundle); - return _va_gov_vamc_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); + return script_library_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); } /** @@ -267,9 +208,9 @@ function va_gov_vamc_update_9004(&$sandbox) { */ function va_gov_vamc_update_9005(&$sandbox) { $build_bundle = 'vamc_system_medical_records_offi'; - _va_gov_vamc_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); + script_library_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); _va_gov_vamc_create_system_content_pages($sandbox, $build_bundle); - return _va_gov_vamc_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); + return script_library_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); } /** @@ -277,9 +218,9 @@ function va_gov_vamc_update_9005(&$sandbox) { */ function va_gov_vamc_update_9006(&$sandbox) { $build_bundle = 'vamc_system_register_for_care'; - _va_gov_vamc_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); + script_library_sandbox_init($sandbox, '_va_gov_vamc_get_systems_to_process', [$build_bundle]); _va_gov_vamc_create_system_content_pages($sandbox, $build_bundle); - return _va_gov_vamc_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); + return script_library_sandbox_complete($sandbox, "Created @total {$build_bundle} nodes."); } /** diff --git a/scripts/content/VACMS-15559-migrate-service-location-from-node-to-paragraph.php b/scripts/content/VACMS-15559-migrate-service-location-from-node-to-paragraph.php new file mode 100644 index 0000000000..945ca7581a --- /dev/null +++ b/scripts/content/VACMS-15559-migrate-service-location-from-node-to-paragraph.php @@ -0,0 +1,52 @@ + 0]; + do { + print(va_gov_vamc_deploy_migrate_service_data_to_service_location($sandbox)); + } while ($sandbox['#finished'] < 1); + // Migration is done. + script_library_skip_post_api_data_check(FALSE); + return "Script run complete."; +} + +/** + * Migrate some facility service data to service location paragraphs. + */ +function va_gov_vamc_deploy_migrate_service_data_to_service_location(&$sandbox) { + $source_bundle = 'health_care_local_health_service'; + script_library_sandbox_init($sandbox, 'get_nids_of_type', [$source_bundle, FALSE]); + new ServiceLocationMigration($sandbox); + $new_service_locations = $sandbox['service_locations_created_count'] ?? 0; + $updated_service_locations = $sandbox['service_locations_updated_count'] ?? 0; + $forward_revisions = $sandbox['forward_revisions_count'] ?? 0; + return script_library_sandbox_complete($sandbox, "migrated @total {$source_bundle} nodes into {$new_service_locations} new service_location paragraphs, and {$updated_service_locations} updated. Also updated {$forward_revisions} forward revisions."); +}