Infrastructure for the sync process
- Update settings UI - Start sync via scheduled actions when sync is enabled - Auto-switch authoritative table on sync finished if so configured - Disable auto-switch if sync is disabled - Show initial and current count of orders pending sync in settings UI
This commit is contained in:
parent
aa8b7a4a6b
commit
0114d3b5d6
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use Automattic\Jetpack\Constants;
|
use Automattic\Jetpack\Constants;
|
||||||
|
use Automattic\WooCommerce\Utilities\ArrayUtil;
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
|
@ -273,6 +274,12 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'info':
|
||||||
|
echo '<tr><th scope="row" class="titledesc"/><td style="' . esc_html( $value['css'] ) . '">';
|
||||||
|
echo wp_kses_post( wpautop( wptexturize( $value['text'] ) ) );
|
||||||
|
echo '</td></tr>';
|
||||||
|
break;
|
||||||
|
|
||||||
// Section Ends.
|
// Section Ends.
|
||||||
case 'sectionend':
|
case 'sectionend':
|
||||||
if ( ! empty( $value['id'] ) ) {
|
if ( ! empty( $value['id'] ) ) {
|
||||||
|
@ -417,6 +424,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
|
||||||
// Radio inputs.
|
// Radio inputs.
|
||||||
case 'radio':
|
case 'radio':
|
||||||
$option_value = $value['value'];
|
$option_value = $value['value'];
|
||||||
|
$disabled_values = ArrayUtil::get_value_or_default($value, 'disabled', array());
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<tr valign="top">
|
<tr valign="top">
|
||||||
|
@ -435,6 +443,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
|
||||||
name="<?php echo esc_attr( $value['id'] ); ?>"
|
name="<?php echo esc_attr( $value['id'] ); ?>"
|
||||||
value="<?php echo esc_attr( $key ); ?>"
|
value="<?php echo esc_attr( $key ); ?>"
|
||||||
type="radio"
|
type="radio"
|
||||||
|
<?php if(in_array($key, $disabled_values)) { echo 'disabled'; } ?>
|
||||||
style="<?php echo esc_attr( $value['css'] ); ?>"
|
style="<?php echo esc_attr( $value['css'] ); ?>"
|
||||||
class="<?php echo esc_attr( $value['class'] ); ?>"
|
class="<?php echo esc_attr( $value['class'] ); ?>"
|
||||||
<?php echo implode( ' ', $custom_attributes ); // WPCS: XSS ok. ?>
|
<?php echo implode( ' ', $custom_attributes ); // WPCS: XSS ok. ?>
|
||||||
|
|
|
@ -92,6 +92,38 @@ class CustomOrdersTableController {
|
||||||
999,
|
999,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
|
||||||
|
add_filter(
|
||||||
|
'updated_option',
|
||||||
|
function( $option, $old_value, $value ) {
|
||||||
|
$this->process_updated_option( $option, $old_value, $value );
|
||||||
|
},
|
||||||
|
999,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
add_filter(
|
||||||
|
'pre_update_option',
|
||||||
|
function( $value, $option, $old_value ) {
|
||||||
|
return $this->process_pre_update_option( $option, $old_value, $value );
|
||||||
|
},
|
||||||
|
999,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
add_filter(
|
||||||
|
DataSynchronizer::PENDING_SYNCHRONIZATION_FINISHED_ACTION,
|
||||||
|
function() {
|
||||||
|
$this->process_sync_finished();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'woocommerce_update_options_advanced_custom_data_stores',
|
||||||
|
function() {
|
||||||
|
$this->process_options_updated();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -213,6 +245,7 @@ class CustomOrdersTableController {
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->data_synchronizer->create_database_tables();
|
$this->data_synchronizer->create_database_tables();
|
||||||
|
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -258,40 +291,182 @@ class CustomOrdersTableController {
|
||||||
return $settings;
|
return $settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
$title_item = array(
|
|
||||||
'title' => __( 'Custom orders tables', 'woocommerce' ),
|
|
||||||
'type' => 'title',
|
|
||||||
'desc' => sprintf(
|
|
||||||
/* translators: %1$s = <strong> tag, %2$s = </strong> tag. */
|
|
||||||
__( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ),
|
|
||||||
'<strong>',
|
|
||||||
'</strong>'
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( $this->data_synchronizer->check_orders_table_exists() ) {
|
if ( $this->data_synchronizer->check_orders_table_exists() ) {
|
||||||
$settings[] = $title_item;
|
$settings[] = array(
|
||||||
|
'title' => __( 'Custom orders tables', 'woocommerce' ),
|
||||||
|
'type' => 'title',
|
||||||
|
'id' => 'cot-title',
|
||||||
|
'desc' => sprintf(
|
||||||
|
/* translators: %1$s = <strong> tag, %2$s = </strong> tag. */
|
||||||
|
__( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ),
|
||||||
|
'<strong>',
|
||||||
|
'</strong>'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$sync_status = $this->data_synchronizer->get_sync_status();
|
||||||
|
$sync_is_pending = 0 !== $sync_status['current_pending_count'];
|
||||||
|
|
||||||
$settings[] = array(
|
$settings[] = array(
|
||||||
'title' => __( 'Enable tables usage', 'woocommerce' ),
|
'title' => __( 'Data store for orders', 'woocommerce' ),
|
||||||
'desc' => __( 'Use the custom orders tables as the main orders data store.', 'woocommerce' ),
|
|
||||||
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
|
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
|
||||||
'default' => 'no',
|
'default' => 'no',
|
||||||
'type' => 'checkbox',
|
'type' => 'radio',
|
||||||
|
'options' => array(
|
||||||
|
'yes' => __( 'Use the WooCommerce orders tables', 'woocommerce' ),
|
||||||
|
'no' => __( 'Use the WordPress posts table', 'woocommerce' ),
|
||||||
|
),
|
||||||
'checkboxgroup' => 'start',
|
'checkboxgroup' => 'start',
|
||||||
|
'disabled' => $sync_is_pending ? array( 'yes', 'no' ) : array(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ( $sync_is_pending ) {
|
||||||
|
$initial_pending_count = $sync_status['initial_pending_count'];
|
||||||
|
if ( $initial_pending_count ) {
|
||||||
|
$text = sprintf(
|
||||||
|
/* translators: %1$s=current number of orders pending sync, %2$s=initial number of orders pending sync */
|
||||||
|
__( 'There are %1$s orders (out of a total of %2$s) pending sync!', 'woocommerce' ),
|
||||||
|
$sync_status['current_pending_count'],
|
||||||
|
$initial_pending_count
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$text = sprintf(
|
||||||
|
/* translators: %1$s=current number of orders pending sync */
|
||||||
|
__( 'There are %1$s orders pending sync!', 'woocommerce' ),
|
||||||
|
$sync_status['current_pending_count']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $this->data_synchronizer->pending_data_sync_is_in_progress() ) {
|
||||||
|
$text .= __( '<br/>Syncrhonization for these orders is currently in progress.', 'woocommerce' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$text .= __( "<br/>The authoritative table can't be changed until sync completes.", 'woocommerce' );
|
||||||
|
|
||||||
|
$settings[] = array(
|
||||||
|
'type' => 'info',
|
||||||
|
'id' => 'cot-out-of-sync-warning',
|
||||||
|
'css' => 'color: #C00000',
|
||||||
|
'text' => $text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings[] = array(
|
||||||
|
'desc' => __( 'Keep the posts table and the orders tables synchronized', 'woocommerce' ),
|
||||||
|
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
|
||||||
|
'type' => 'checkbox',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $sync_is_pending ) {
|
||||||
|
if ( $this->data_synchronizer->data_sync_is_enabled() ) {
|
||||||
|
$message = $this->custom_orders_table_usage_is_enabled() ?
|
||||||
|
__( 'Switch to using the posts table as the authoritative data store for orders when sync finishes', 'woocommerce' ) :
|
||||||
|
__( 'Switch to using the orders table as the authoritative data store for orders when sync finishes', 'woocommerce' );
|
||||||
|
$settings[] = array(
|
||||||
|
'desc' => $message,
|
||||||
|
'id' => DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION,
|
||||||
|
'type' => 'checkbox',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$title_item['desc'] = sprintf(
|
$settings[] = array(
|
||||||
/* translators: %1$s = <em> tag, %2$s = </em> tag. */
|
'title' => __( 'Custom orders tables', 'woocommerce' ),
|
||||||
__( 'Create the tables first by going to %1$sWooCommerce > Status > Tools%2$s and running %1$sCreate the custom orders tables%2$s.', 'woocommerce' ),
|
'type' => 'title',
|
||||||
'<em>',
|
'desc' => sprintf(
|
||||||
'</em>'
|
/* translators: %1$s = <em> tag, %2$s = </em> tag. */
|
||||||
|
__( 'Create the tables first by going to %1$sWooCommerce > Status > Tools%2$s and running %1$sCreate the custom orders tables%2$s.', 'woocommerce' ),
|
||||||
|
'<em>',
|
||||||
|
'</em>'
|
||||||
|
),
|
||||||
);
|
);
|
||||||
$settings[] = $title_item;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings[] = array( 'type' => 'sectionend' );
|
$settings[] = array( 'type' => 'sectionend' );
|
||||||
|
|
||||||
return $settings;
|
return $settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for the individual setting updated hook.
|
||||||
|
*
|
||||||
|
* @param string $option Setting name.
|
||||||
|
* @param mixed $old_value Old value of the setting.
|
||||||
|
* @param mixed $value New value of the setting.
|
||||||
|
*/
|
||||||
|
private function process_updated_option( $option, $old_value, $value ) {
|
||||||
|
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
|
||||||
|
$this->data_synchronizer->cleanup_synchronization_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for the setting pre-update hook.
|
||||||
|
* We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
|
||||||
|
*
|
||||||
|
* @param string $option Setting name.
|
||||||
|
* @param mixed $old_value Old value of the setting.
|
||||||
|
* @param mixed $value New value of the setting.
|
||||||
|
*
|
||||||
|
* @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
|
||||||
|
*/
|
||||||
|
private function process_pre_update_option( $option, $old_value, $value ) {
|
||||||
|
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
|
||||||
|
if ( $sync_is_pending ) {
|
||||||
|
throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for the synchronization finished hook.
|
||||||
|
* Here we switch the authoritative table if needed.
|
||||||
|
*/
|
||||||
|
private function process_sync_finished() {
|
||||||
|
if ( $this->auto_flip_authoritative_table_enabled() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' );
|
||||||
|
|
||||||
|
if ( $this->custom_orders_table_usage_is_enabled() ) {
|
||||||
|
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
|
||||||
|
} else {
|
||||||
|
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the automatic authoritative table switch setting set?
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function auto_flip_authoritative_table_enabled(): bool {
|
||||||
|
return 'yes' === get_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for the all settings updated hook.
|
||||||
|
*/
|
||||||
|
private function process_options_updated() {
|
||||||
|
$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();
|
||||||
|
|
||||||
|
// Disabling the sync implies disabling the automatic authoritative table switch too.
|
||||||
|
if ( ! $data_sync_is_enabled && $this->auto_flip_authoritative_table_enabled() ) {
|
||||||
|
update_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling the sync implies starting it too, if needed.
|
||||||
|
// We do this check here, and not in process_pre_update_option, so that if for some reason
|
||||||
|
// the setting is enabled but no sync is in process, sync will start by just saving the
|
||||||
|
// settings even without modifying them.
|
||||||
|
if ( $data_sync_is_enabled && ! $this->data_synchronizer->pending_data_sync_is_in_progress() ) {
|
||||||
|
$this->data_synchronizer->start_synchronizing_pending_orders();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,13 @@ defined( 'ABSPATH' ) || exit;
|
||||||
*/
|
*/
|
||||||
class DataSynchronizer {
|
class DataSynchronizer {
|
||||||
|
|
||||||
|
const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled';
|
||||||
|
const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count';
|
||||||
|
const AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION = 'woocommerce_auto_flip_authoritative_table_roles';
|
||||||
|
const PENDING_SYNC_IS_IN_PROGRESS_OPTION = 'woocommerce_custom_orders_table_pending_sync_in_progress';
|
||||||
|
const ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK = 'woocommerce_run_orders_sync_callback';
|
||||||
|
const PENDING_SYNCHRONIZATION_FINISHED_ACTION = 'woocommerce_orders_sync_finished';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The data store object to use.
|
* The data store object to use.
|
||||||
*
|
*
|
||||||
|
@ -31,7 +38,17 @@ class DataSynchronizer {
|
||||||
*/
|
*/
|
||||||
private $database_util;
|
private $database_util;
|
||||||
|
|
||||||
// TODO: Add a constructor to handle hooks as appropriate.
|
/**
|
||||||
|
* Class constructor.
|
||||||
|
*/
|
||||||
|
public function __construct() {
|
||||||
|
add_action(
|
||||||
|
self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK,
|
||||||
|
function() {
|
||||||
|
$this->do_pending_orders_synchronization();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class initialization, invoked by the DI container.
|
* Class initialization, invoked by the DI container.
|
||||||
|
@ -74,5 +91,113 @@ class DataSynchronizer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the data sync between old and new tables currently enabled?
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function data_sync_is_enabled(): bool {
|
||||||
|
return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a sync process currently in progress?
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function pending_data_sync_is_in_progress(): bool {
|
||||||
|
return 'yes' === get_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current sync process status.
|
||||||
|
* The information is meaningful only if pending_data_sync_is_in_progress return true.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function get_sync_status() {
|
||||||
|
return array(
|
||||||
|
'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ),
|
||||||
|
'current_pending_count' => $this->get_current_orders_pending_sync_count(),
|
||||||
|
'auto_flip' => 'yes' === get_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ),
|
||||||
|
'sync_in_progress' => $this->pending_data_sync_is_in_progress(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate how many orders need to be synchronized currently.
|
||||||
|
*/
|
||||||
|
public function get_current_orders_pending_sync_count(): int {
|
||||||
|
// TODO: get this value by querying the database.
|
||||||
|
return get_option( 'woocommerce_fake_orders_pending_sync_count', 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start an orders synchronization process.
|
||||||
|
* This will setup the appropriate status information and schedule the first synchronization batch.
|
||||||
|
*/
|
||||||
|
public function start_synchronizing_pending_orders() {
|
||||||
|
$initial_pending_count = $this->get_current_orders_pending_sync_count();
|
||||||
|
if ( 0 === $initial_pending_count ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, $initial_pending_count );
|
||||||
|
|
||||||
|
$queue = WC()->get_instance_of( \WC_Queue::class );
|
||||||
|
$queue->cancel_all( self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK );
|
||||||
|
|
||||||
|
update_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION, 'yes' );
|
||||||
|
$this->schedule_pending_orders_synchronization();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next orders synchronization batch.
|
||||||
|
*/
|
||||||
|
private function schedule_pending_orders_synchronization() {
|
||||||
|
$queue = WC()->get_instance_of( \WC_Queue::class );
|
||||||
|
$queue->schedule_single(
|
||||||
|
WC()->call_function( 'time' ) + 1,
|
||||||
|
self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK,
|
||||||
|
array(),
|
||||||
|
'woocommerce-db-updates'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one orders synchronization batch.
|
||||||
|
*/
|
||||||
|
private function do_pending_orders_synchronization() {
|
||||||
|
if ( ! $this->pending_data_sync_is_in_progress() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Syncrhonize a batch of orders.
|
||||||
|
$fake_count = (int) get_option( 'woocommerce_fake_orders_pending_sync_count', 0 );
|
||||||
|
if ( $fake_count > 0 ) {
|
||||||
|
update_option( 'woocommerce_fake_orders_pending_sync_count', $fake_count - 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 0 === $this->get_current_orders_pending_sync_count() ) {
|
||||||
|
$this->cleanup_synchronization_state();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to signal that the orders tables synchronization process has finised (nothing left to synchronize).
|
||||||
|
*/
|
||||||
|
do_action( self::PENDING_SYNCHRONIZATION_FINISHED_ACTION );
|
||||||
|
} else {
|
||||||
|
$this->schedule_pending_orders_synchronization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all the synchronization status information,
|
||||||
|
* because the process has been disabled by the user via settings,
|
||||||
|
* or because there's nothing left to syncrhonize.
|
||||||
|
*/
|
||||||
|
public function cleanup_synchronization_state() {
|
||||||
|
delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION );
|
||||||
|
delete_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION );
|
||||||
|
delete_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue