Add CLI tool to remove order data from legacy tables (#42848)
* Introduce `LegacyDataHandler` for handling legacy orders in the HPOS datastore * Add methods to count and obtain orders subject to cleanup * First pass at metadata cleanup for orders * Add unit tests * Implement `wc hpos cleanup` CLI tool * Make PHPCS happy * Add changelog * Change error to warning * Improve tests * Fix unit tests * Allow cleaning up of placeholders with meta * Add support for `--force` flag * Update plugins/woocommerce/changelog/enhancement-41914 Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com> * Update plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com> * Update plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com> * Exclude auto-draft --------- Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
This commit is contained in:
parent
bd6a8d365a
commit
1965dfb63e
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add CLI command `wc hpos cleanup` to cleanup post and post meta data for migrated orders.
|
|
@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable;
|
|||
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
use WP_CLI;
|
||||
|
@ -63,6 +64,7 @@ class CLIRunner {
|
|||
WP_CLI::add_command( 'wc cot verify_cot_data', array( $this, 'verify_cot_data' ) );
|
||||
WP_CLI::add_command( 'wc cot enable', array( $this, 'enable' ) );
|
||||
WP_CLI::add_command( 'wc cot disable', array( $this, 'disable' ) );
|
||||
WP_CLI::add_command( 'wc hpos cleanup', array( $this, 'cleanup_post_data' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -862,4 +864,99 @@ ORDER BY $meta_table.order_id ASC, $meta_table.meta_key ASC;
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When HPOS is enabled, this command lets you remove redundant data from the postmeta table for migrated orders.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <all|id|range>...
|
||||
* : ID or range of orders to clean up.
|
||||
*
|
||||
* [--batch-size=<batch-size>]
|
||||
* : Number of orders to process per batch. Applies only to cleaning up of 'all' orders.
|
||||
* ---
|
||||
* default: 500
|
||||
* ---
|
||||
*
|
||||
* [--force]
|
||||
* : When true, post meta will be cleaned up even if the post appears to have been updated more recently than the order.
|
||||
* ---
|
||||
* default: false
|
||||
* ---
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* # Cleanup post data for order 314.
|
||||
* $ wp wc hpos cleanup 314
|
||||
*
|
||||
* # Cleanup postmeta for orders with IDs betweeen 10 and 100 and order 314.
|
||||
* $ wp wc hpos cleanup 10-100 314
|
||||
*
|
||||
* # Cleanup postmeta for all orders.
|
||||
* wp wc hpos cleanup all
|
||||
*
|
||||
* # Cleanup postmeta for all orders with a batch size of 200 (instead of the default 500).
|
||||
* wp wc hpos cleanup all --batch-size=200
|
||||
*
|
||||
* @param array $args Positional arguments passed to the command.
|
||||
* @param array $assoc_args Associative arguments (options) passed to the command.
|
||||
* @return void
|
||||
*/
|
||||
public function cleanup_post_data( array $args = array(), array $assoc_args = array() ) {
|
||||
if ( ! $this->synchronizer->custom_orders_table_is_authoritative() || $this->synchronizer->data_sync_is_enabled() ) {
|
||||
WP_CLI::error( __( 'Cleanup can only be performed when HPOS is active and compatibility mode is disabled.', 'woocommerce' ) );
|
||||
}
|
||||
$handler = wc_get_container()->get( LegacyDataHandler::class );
|
||||
|
||||
$all_orders = 'all' === $args[0];
|
||||
$force = (bool) ( $assoc_args['force'] ?? false );
|
||||
$q_order_ids = $all_orders ? array() : $args;
|
||||
$q_limit = $all_orders ? absint( $assoc_args['batch-size'] ?? 500 ) : 0; // Limit per batch.
|
||||
|
||||
$order_count = $handler->count_orders_for_cleanup( $q_order_ids );
|
||||
if ( ! $order_count ) {
|
||||
WP_CLI::warning( __( 'No orders to cleanup.', 'woocommerce' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = WP_CLI\Utils\make_progress_bar( __( 'HPOS cleanup', 'woocommerce' ), $order_count );
|
||||
$count = 0;
|
||||
|
||||
// translators: %d is the number of orders to clean up.
|
||||
WP_CLI::log( sprintf( _n( 'Starting cleanup for %d order...', 'Starting cleanup for %d orders...', $order_count, 'woocommerce' ), $order_count ) );
|
||||
|
||||
do {
|
||||
$order_ids = $handler->get_orders_for_cleanup( $q_order_ids, $q_limit );
|
||||
|
||||
foreach ( $order_ids as $order_id ) {
|
||||
try {
|
||||
$handler->cleanup_post_data( $order_id, $force );
|
||||
$count++;
|
||||
|
||||
// translators: %d is an order ID.
|
||||
WP_CLI::debug( sprintf( __( 'Cleanup completed for order %d.', 'woocommerce' ), $order_id ) );
|
||||
} catch ( \Exception $e ) {
|
||||
// translators: %1$d is an order ID, %2$s is an error message.
|
||||
WP_CLI::warning( sprintf( __( 'An error occurred while cleaning up order %1$d: %2$s', 'woocommerce' ), $order_id, $e->getMessage() ) );
|
||||
}
|
||||
|
||||
$progress->tick();
|
||||
}
|
||||
|
||||
if ( ! $all_orders ) {
|
||||
break;
|
||||
}
|
||||
} while ( $order_ids );
|
||||
|
||||
$progress->finish();
|
||||
|
||||
WP_CLI::success(
|
||||
sprintf(
|
||||
// translators: %d is the number of orders that were cleaned up.
|
||||
_n( 'Cleanup completed for %d order.', 'Cleanup completed for %d orders.', $count, 'woocommerce' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
/**
|
||||
* LegacyDataHandler class file.
|
||||
*/
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* This class provides functionality to clean up post data from the posts table when HPOS is authoritative.
|
||||
*/
|
||||
class LegacyDataHandler {
|
||||
|
||||
/**
|
||||
* Instance of the HPOS datastore.
|
||||
*
|
||||
* @var OrdersTableDataStore
|
||||
*/
|
||||
private OrdersTableDataStore $data_store;
|
||||
|
||||
/**
|
||||
* Instance of the DataSynchronizer class.
|
||||
*
|
||||
* @var DataSynchronizer
|
||||
*/
|
||||
private DataSynchronizer $data_synchronizer;
|
||||
|
||||
/**
|
||||
* Class initialization, invoked by the DI container.
|
||||
*
|
||||
* @param OrdersTableDataStore $data_store HPOS datastore instance to use.
|
||||
* @param DataSynchronizer $data_synchronizer DataSynchronizer instance to use.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer ) {
|
||||
$this->data_store = $data_store;
|
||||
$this->data_synchronizer = $data_synchronizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of orders for which legacy post data can be removed.
|
||||
*
|
||||
* @param array $order_ids If provided, total is computed only among IDs in this array, which can be either individual IDs or ranges like "100-200".
|
||||
* @return int Number of orders.
|
||||
*/
|
||||
public function count_orders_for_cleanup( $order_ids = array() ) : int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( $this->build_sql_query_for_cleanup( $order_ids, 'count' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of orders for which legacy post data can be removed.
|
||||
*
|
||||
* @param array $order_ids If provided, result is a subset of the order IDs in this array, which can contain either individual order IDs or ranges like "100-200".
|
||||
* @param int $limit Limit the number of results.
|
||||
* @return array[int] Order IDs.
|
||||
*/
|
||||
public function get_orders_for_cleanup( $order_ids = array(), int $limit = 0 ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'absint',
|
||||
$wpdb->get_col( $this->build_sql_query_for_cleanup( $order_ids, 'ids', $limit ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a SQL statement to either count or obtain IDs for orders in need of cleanup.
|
||||
*
|
||||
* @param array $order_ids If provided, the query will only include orders in this set of order IDs or ID ranges (like "10-100").
|
||||
* @param string $result Use 'count' to build a query that returns a count. Otherwise, the query will return order IDs.
|
||||
* @param integer $limit If provided, the query will be limited to this number of results. Does not apply when $result is 'count'.
|
||||
* @return string SQL query.
|
||||
*/
|
||||
private function build_sql_query_for_cleanup( array $order_ids = array(), string $result = 'ids', int $limit = 0 ): string {
|
||||
global $wpdb;
|
||||
|
||||
$sql_where = '';
|
||||
|
||||
if ( $order_ids ) {
|
||||
// Expand ranges in $order_ids as needed to build the WHERE clause.
|
||||
$where_ids = array();
|
||||
$where_ranges = array();
|
||||
|
||||
foreach ( $order_ids as &$arg ) {
|
||||
if ( is_numeric( $arg ) ) {
|
||||
$where_ids[] = absint( $arg );
|
||||
} elseif ( preg_match( '/^(\d+)-(\d+)$/', $arg, $matches ) ) {
|
||||
$where_ranges[] = $wpdb->prepare( "({$wpdb->posts}.ID >= %d AND {$wpdb->posts}.ID <= %d)", absint( $matches[1] ), absint( $matches[2] ) );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $where_ids ) {
|
||||
$where_ranges[] = "{$wpdb->posts}.ID IN (" . implode( ',', $where_ids ) . ')';
|
||||
}
|
||||
|
||||
if ( ! $where_ranges ) {
|
||||
$sql_where .= '1=0';
|
||||
} else {
|
||||
$sql_where .= '(' . implode( ' OR ', $where_ranges ) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
$sql_where .= $sql_where ? ' AND ' : '';
|
||||
|
||||
// Post type handling.
|
||||
$sql_where .= '(';
|
||||
$sql_where .= "{$wpdb->posts}.post_type IN ('" . implode( "', '", esc_sql( wc_get_order_types( 'cot-migration' ) ) ) . "')";
|
||||
$sql_where .= $wpdb->prepare(
|
||||
" OR (post_type = %s AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = {$wpdb->posts}.ID))",
|
||||
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE
|
||||
);
|
||||
$sql_where .= ')';
|
||||
|
||||
// Exclude 'auto-draft' since those go away on their own.
|
||||
$sql_where .= $wpdb->prepare( " AND {$wpdb->posts}.post_status != %s", 'auto-draft' );
|
||||
|
||||
if ( 'count' === $result ) {
|
||||
$sql_fields = 'COUNT(*)';
|
||||
$sql_limit = '';
|
||||
} else {
|
||||
$sql_fields = 'ID';
|
||||
$sql_limit = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
|
||||
}
|
||||
|
||||
return "SELECT {$sql_fields} FROM {$wpdb->posts} WHERE {$sql_where} {$sql_limit}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a cleanup of post data for a given order and also converts the post to the placeholder type in the backup table.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
* @param bool $skip_checks Whether to skip the checks that happen before the order is cleaned up.
|
||||
* @return void
|
||||
* @throws \Exception When an error occurs.
|
||||
*/
|
||||
public function cleanup_post_data( int $order_id, bool $skip_checks = false ): void {
|
||||
global $wpdb;
|
||||
|
||||
$order = wc_get_order( $order_id );
|
||||
if ( ! $order ) {
|
||||
// translators: %d is an order ID.
|
||||
throw new \Exception( sprintf( __( '%d is not a valid order ID.', 'woocommerce' ), $order_id ) );
|
||||
}
|
||||
|
||||
if ( ! $skip_checks && ! $this->is_order_newer_than_post( $order ) ) {
|
||||
throw new \Exception( sprintf( __( 'Data in posts table appears to be more recent than in HPOS tables.', 'woocommerce' ) ) );
|
||||
}
|
||||
|
||||
$meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d", $order->get_id() ) );
|
||||
foreach ( $meta_ids as $meta_id ) {
|
||||
delete_metadata_by_mid( 'post', $meta_id );
|
||||
}
|
||||
|
||||
// wp_update_post() changes the post modified date, so we do this manually.
|
||||
// Also, we suspect using wp_update_post() could lead to integrations mistakenly updating the entity.
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->posts} SET post_type = %s, post_status = %s WHERE ID = %d",
|
||||
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE,
|
||||
'draft',
|
||||
$order->get_id()
|
||||
)
|
||||
);
|
||||
|
||||
clean_post_cache( $order->get_id() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an HPOS-backed order is newer than the corresponding post.
|
||||
*
|
||||
* @param int|\WC_Order $order An HPOS order.
|
||||
* @return bool TRUE if the order is up to date with the corresponding post.
|
||||
* @throws \Exception When the order is not an HPOS order.
|
||||
*/
|
||||
private function is_order_newer_than_post( $order ): bool {
|
||||
$order = is_a( $order, 'WC_Order' ) ? $order : wc_get_order( absint( $order ) );
|
||||
|
||||
if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
|
||||
throw new \Exception( __( 'Order is not an HPOS order.', 'woocommerce' ) );
|
||||
}
|
||||
|
||||
$post = get_post( $order->get_id() );
|
||||
if ( ! $post || $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post->post_type ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$order_modified_gmt = $order->get_date_modified() ?? $order->get_date_created();
|
||||
$order_modified_gmt = $order_modified_gmt ? $order_modified_gmt->getTimestamp() : 0;
|
||||
$post_modified_gmt = $post->post_modified_gmt ?? $post->post_date_gmt;
|
||||
$post_modified_gmt = ( $post_modified_gmt && '0000-00-00 00:00:00' !== $post_modified_gmt ) ? wc_string_to_timestamp( $post_modified_gmt ) : 0;
|
||||
|
||||
return $order_modified_gmt >= $post_modified_gmt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore
|
|||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
|
||||
use Automattic\WooCommerce\Internal\Features\FeaturesController;
|
||||
|
@ -42,6 +43,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
|
|||
OrdersTableRefundDataStore::class,
|
||||
OrderCache::class,
|
||||
OrderCacheController::class,
|
||||
LegacyDataHandler::class,
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -79,5 +81,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
|
|||
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
$this->share( CLIRunner::class )->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class, PostsToOrdersMigrationController::class ) );
|
||||
}
|
||||
|
||||
$this->share( LegacyDataHandler::class )->addArguments( array( OrdersTableDataStore::class, DataSynchronizer::class ) );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
|
||||
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
|
||||
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
|
||||
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
|
||||
|
||||
/**
|
||||
* Class OrdersTableQueryTests.
|
||||
*/
|
||||
class LegacyDataHandlerTests extends WC_Unit_Test_Case {
|
||||
use HPOSToggleTrait;
|
||||
|
||||
/**
|
||||
* @var LegacyDataHandler
|
||||
*/
|
||||
private $sut;
|
||||
|
||||
/**
|
||||
* Initializes system under test.
|
||||
*/
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
|
||||
$this->setup_cot();
|
||||
|
||||
$this->sut = wc_get_container()->get( LegacyDataHandler::class );
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys system under test.
|
||||
*/
|
||||
public function tearDown(): void {
|
||||
parent::tearDown();
|
||||
$this->clean_up_cot_setup();
|
||||
remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the cleanup of legacy data.
|
||||
*/
|
||||
public function test_post_data_cleanup() {
|
||||
$this->enable_cot_sync();
|
||||
$orders = array(
|
||||
OrderHelper::create_order(),
|
||||
OrderHelper::create_order(),
|
||||
);
|
||||
$this->disable_cot_sync();
|
||||
|
||||
// Confirm orders have been synced up (i.e. are not placeholders) and contain metadata.
|
||||
foreach ( $orders as $order ) {
|
||||
$this->assertEquals( 'shop_order', get_post_type( $order->get_id() ) );
|
||||
$this->assertNotEmpty( get_post_meta( $order->get_id() ) );
|
||||
}
|
||||
|
||||
// Check that counts are working ok.
|
||||
$this->assertEquals( 1, $this->sut->count_orders_for_cleanup( array( $orders[0]->get_id() ) ) );
|
||||
$this->assertEquals( 2, $this->sut->count_orders_for_cleanup() );
|
||||
|
||||
// Cleanup one of the orders.
|
||||
$this->sut->cleanup_post_data( $orders[0]->get_id() );
|
||||
|
||||
// Confirm metadata has been removed and post type has been reset to placeholder.
|
||||
$this->assertEmpty( get_post_meta( $orders[0]->get_id() ) );
|
||||
$this->assertEquals( 'shop_order_placehold', get_post_type( $orders[0]->get_id() ) );
|
||||
|
||||
// Check counts.
|
||||
$this->assertEquals( 0, $this->sut->count_orders_for_cleanup( array( $orders[0]->get_id() ) ) );
|
||||
$this->assertEquals( 1, $this->sut->count_orders_for_cleanup() );
|
||||
|
||||
// Confirm that we now have 1 unsynced order (due to the removal of the backup data).
|
||||
$this->assertEquals( 1, wc_get_container()->get( DataSynchronizer::class )->get_current_orders_pending_sync_count() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that cleanup for a non-existent order throws an exception.
|
||||
*/
|
||||
public function test_post_data_cleanup_non_existent() {
|
||||
$this->expectException( \Exception::class );
|
||||
$this->sut->cleanup_post_data( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests `get_orders_for_cleanup()` with various arguments, including ranges of orders and individual order IDs.
|
||||
*/
|
||||
public function test_get_orders_for_cleanup() {
|
||||
// Create a few orders.
|
||||
$this->enable_cot_sync();
|
||||
$order_ids = array();
|
||||
for ( $i = 0; $i < 10; $i++ ) {
|
||||
$order_id = OrderHelper::create_order()->get_id();
|
||||
$order_ids[] = $order_id;
|
||||
}
|
||||
$this->disable_cot_sync();
|
||||
|
||||
$this->assertCount( 0, $this->sut->get_orders_for_cleanup( array( max( $order_ids ) + 1 ) ) );
|
||||
$this->assertCount( 10, $this->sut->get_orders_for_cleanup() );
|
||||
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( $order_ids ) );
|
||||
|
||||
$interval = min( $order_ids ) . '-' . max( $order_ids );
|
||||
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( array( $interval ) ) );
|
||||
$this->assertCount( 0, $this->sut->get_orders_for_cleanup( array( '300-2' ) ) );
|
||||
|
||||
$slice = array_slice( $order_ids, 5 );
|
||||
$interval = min( $slice ) . '-' . max( $slice );
|
||||
$this->assertCount( 5, $this->sut->get_orders_for_cleanup( $slice ) );
|
||||
$this->assertCount( 5, $this->sut->get_orders_for_cleanup( array( $interval ) ) );
|
||||
$this->assertCount( 7, $this->sut->get_orders_for_cleanup( array( $order_ids[0], $order_ids[1], $interval ) ) );
|
||||
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( array( $interval, '0-' . min( $slice ) ) ) );
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue