Файловый менеджер - Редактировать - /home/tuudkjt/globeasy/wp-includes/ID3/API.tar
Назад
ProductCategories.php 0000777 00000000712 15252174340 0010715 0 ustar 00 <?php /** * REST API Product Categories Controller * * Handles requests to /products/categories. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product categories controller. * * @internal * @extends WC_REST_Product_Categories_Controller */ class ProductCategories extends \WC_REST_Product_Categories_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; } ProductVariations.php 0000777 00000014042 15252174340 0010750 0 ustar 00 <?php /** * REST API Product Variations Controller * * Handles requests to /products/variations. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductType; defined( 'ABSPATH' ) || exit; /** * Product variations controller. * * @internal * @extends WC_REST_Product_Variations_Controller */ class ProductVariations extends \WC_REST_Product_Variations_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for products. */ public function register_routes() { parent::register_routes(); // Add a route for listing variations without specifying the parent product ID. register_rest_route( $this->namespace, '/variations', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar product name, sku, or attribute value.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add in conditional search filters for variations. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->prepare( 'attr_search_meta.meta_value LIKE %s', $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); if ( wc_product_sku_enabled() ) { $conditions[] = $wpdb->prepare( 'wc_product_meta_lookup.sku LIKE %s', $like ); } $where .= ' AND (' . implode( ' OR ', $conditions ) . ')'; } return $where; } /** * Join posts meta tables when variation search query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $join .= " LEFT JOIN {$wpdb->postmeta} AS attr_search_meta ON {$wpdb->posts}.ID = attr_search_meta.post_id AND attr_search_meta.meta_key LIKE 'attribute_%' "; } if ( wc_product_sku_enabled() && ! strstr( $join, 'wc_product_meta_lookup' ) ) { $join .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $join; } /** * Add product name and sku filtering to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = $request['search']; unset( $args['s'] ); } // Retrieve variations without specifying a parent product. if ( "/{$this->namespace}/variations" === $request->get_route() ) { unset( $args['post_parent'] ); } return $args; } /** * Get a collection of posts and add the post title filter option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10 ); return $response; } /** * Get the Product's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['properties']['name'] = array( 'description' => __( 'Product parent name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), ); $schema['properties']['type'] = array( 'description' => __( 'Product type.', 'woocommerce' ), 'type' => 'string', 'default' => ProductType::VARIATION, 'enum' => array( ProductType::VARIATION ), 'context' => array( 'view', 'edit' ), ); $schema['properties']['parent_id'] = array( 'description' => __( 'Product parent ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), ); return $schema; } /** * Prepare a single variation output for response. * * @param WC_Data $object Object data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_object_for_response( $object, $request ) { $context = empty( $request['context'] ) ? 'view' : $request['context']; $response = parent::prepare_object_for_response( $object, $request ); $data = $response->get_data(); $data['name'] = $object->get_name( $context ); $data['type'] = $object->get_type(); $data['parent_id'] = $object->get_parent_id( $context ); $response->set_data( $data ); return $response; } } MarketingCampaigns.php 0000777 00000023214 15252174340 0011035 0 ustar 00 <?php /** * REST API MarketingCampaigns Controller * * Handles requests to /marketing/campaigns. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingCampaign; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use Automattic\WooCommerce\Admin\Marketing\Price; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingCampaigns Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingCampaigns extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/campaigns'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to view marketing campaigns. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Returns an aggregated array of marketing campaigns for all active marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); // Aggregate the campaigns from all registered marketing channels. $responses = array(); foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { foreach ( $channel->get_campaigns() as $campaign ) { $response = $this->prepare_item_for_response( $campaign, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } } // Pagination. $page = $request['page']; $items_per_page = $request['per_page']; $offset = ( $page - 1 ) * $items_per_page; $paginated_results = array_slice( $responses, $offset, $items_per_page ); $response = rest_ensure_response( $paginated_results ); $total_campaigns = count( $responses ); $max_pages = ceil( $total_campaigns / $items_per_page ); $response->header( 'X-WP-Total', $total_campaigns ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); // Add previous and next page links to response header. $request_params = $request->get_query_params(); $base = add_query_arg( urlencode_deep( $request_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } /** * Get formatted price based on Price type. * * This uses plugins/woocommerce/i18n/currency-info.php and plugins/woocommerce/i18n/locale-info.php to get option object based on $price->currency. * * Example: * * - When $price->currency is 'USD' and $price->value is '1000', it should return '$1000.00'. * - When $price->currency is 'JPY' and $price->value is '1000', it should return '¥1,000'. * - When $price->currency is 'AED' and $price->value is '1000', it should return '5.000,00 د.إ'. * * @param Price $price Price object. * @return String formatted price. */ private function get_formatted_price( $price ) { // Get $num_decimals to be passed to wc_price. $locale_info_all = include WC()->plugin_path() . '/i18n/locale-info.php'; $locale_index = array_search( $price->get_currency(), array_column( $locale_info_all, 'currency_code' ), true ); $locale = array_values( $locale_info_all )[ $locale_index ]; $num_decimals = $locale['num_decimals']; // Get $currency_info based on user locale or default locale. $currency_locales = $locale['locales']; $user_locale = get_user_locale(); $currency_info = $currency_locales[ $user_locale ] ?? $currency_locales['default']; // Get $price_format to be passed to wc_price. $currency_pos = $currency_info['currency_pos']; $currency_formats = array( 'left' => '%1$s%2$s', 'right' => '%2$s%1$s', 'left_space' => '%1$s %2$s', 'right_space' => '%2$s %1$s', ); $price_format = $currency_formats[ $currency_pos ] ?? $currency_formats['left']; $price_value = wc_format_decimal( $price->get_value() ); $price_formatted = wc_price( $price_value, array( 'currency' => $price->get_currency(), 'decimal_separator' => $currency_info['decimal_sep'], 'thousand_separator' => $currency_info['thousand_sep'], 'decimals' => $num_decimals, 'price_format' => $price_format, ) ); return html_entity_decode( wp_strip_all_tags( $price_formatted ) ); } /** * Prepares the item for the REST response. * * @param MarketingCampaign $item WordPress representation of the item. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function prepare_item_for_response( $item, $request ) { $data = array( 'id' => $item->get_id(), 'channel' => $item->get_type()->get_channel()->get_slug(), 'title' => $item->get_title(), 'manage_url' => $item->get_manage_url(), ); if ( $item->get_cost() instanceof Price ) { $data['cost'] = array( 'value' => wc_format_decimal( $item->get_cost()->get_value() ), 'currency' => $item->get_cost()->get_currency(), 'formatted' => $this->get_formatted_price( $item->get_cost() ), ); } if ( $item->get_sales() instanceof Price ) { $data['sales'] = array( 'value' => wc_format_decimal( $item->get_sales()->get_value() ), 'currency' => $item->get_sales()->get_currency(), 'formatted' => $this->get_formatted_price( $item->get_sales() ), ); } $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_campaign', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'The unique identifier for the marketing campaign.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'channel' => array( 'description' => __( 'The unique identifier for the marketing channel that this campaign belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Title of the marketing campaign.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'manage_url' => array( 'description' => __( 'URL to the campaign management page.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'cost' => array( 'description' => __( 'Cost of the marketing campaign.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'type' => 'object', 'properties' => array( 'value' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'currency' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ), 'sales' => array( 'description' => __( 'Sales of the marketing campaign.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'type' => 'object', 'properties' => array( 'value' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'currency' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Retrieves the query params for the collections. * * @return array Query parameters for the collection. */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['search'] ); return $params; } } Coupons.php 0000777 00000004232 15252174340 0006716 0 ustar 00 <?php /** * REST API Coupons Controller * * Handles requests to /coupons/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Coupons controller. * * @internal * @extends WC_REST_Coupons_Controller */ class Coupons extends \WC_REST_Coupons_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Limit results to coupons with codes matching a given string.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add coupon code searching to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = $request['search']; $args['s'] = false; } return $args; } /** * Get a collection of posts and add the code search option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10 ); return $response; } /** * Add code searching to the WP Query * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_search_code_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $code_like = '%' . $wpdb->esc_like( $search ) . '%'; $where .= $wpdb->prepare( "AND {$wpdb->posts}.post_title LIKE %s", $code_like ); } return $where; } } OnboardingTasks.php 0000777 00000100026 15252174340 0010356 0 ustar 00 <?php /** * REST API Onboarding Tasks Controller * * Handles requests to complete various onboarding tasks. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask; defined( 'ABSPATH' ) || exit; /** * Onboarding Tasks Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingTasks extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/tasks'; /** * Duration to millisecond mapping. * * @var array */ protected $duration_to_ms = array( 'day' => DAY_IN_SECONDS * 1000, 'hour' => HOUR_IN_SECONDS * 1000, 'week' => WEEK_IN_SECONDS * 1000, ); /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/import_sample_products', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import_sample_products' ), 'permission_callback' => array( $this, 'create_products_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/create_homepage', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_homepage' ), 'permission_callback' => array( $this, 'create_pages_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/create_product_from_template', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_product_from_template' ), 'permission_callback' => array( $this, 'create_products_permission_check' ), 'args' => array_merge( $this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ), array( 'template_name' => array( 'required' => true, 'type' => 'string', 'description' => __( 'Product template name.', 'woocommerce' ), ), ) ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_tasks' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), 'args' => array( 'ids' => array( 'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => TaskLists::get_list_ids(), 'type' => 'string', ), ), ), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_tasks' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), 'args' => $this->get_task_list_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/hide', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'hide_task_list' ), 'permission_callback' => array( $this, 'hide_task_list_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/unhide', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'unhide_task_list' ), 'permission_callback' => array( $this, 'hide_task_list_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/dismiss', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'dismiss_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_dismiss', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'undo_dismiss_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_-]+)/snooze', array( 'args' => array( 'duration' => array( 'description' => __( 'Time period to snooze the task.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => function( $param, $request, $key ) { return in_array( $param, array_keys( $this->duration_to_ms ), true ); }, ), 'task_list_id' => array( 'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'snooze_task' ), 'permission_callback' => array( $this, 'snooze_task_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/action', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'action_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_snooze', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'undo_snooze_task' ), 'permission_callback' => array( $this, 'snooze_task_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to create a product. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function create_products_permission_check( $request ) { if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to create a product. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function create_pages_permission_check( $request ) { if ( ! wc_rest_check_post_permissions( 'page', 'create' ) || ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create new pages.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_tasks_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has permission to hide task lists. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function hide_task_list_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you are not allowed to hide task lists.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @deprecated 7.8.0 snooze task is deprecated. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function snooze_task_permissions_check( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to snooze onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Import sample products from given CSV path. * * @param string $csv_file CSV file path. * @return WP_Error|WP_REST_Response */ public static function import_sample_products_from_csv( $csv_file ) { include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; if ( file_exists( $csv_file ) && class_exists( 'WC_Product_CSV_Importer' ) ) { // Override locale so we can return mappings from WooCommerce in English language stores. add_filter( 'locale', '__return_false', 9999 ); $importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' ); $args = array( 'parse' => true, 'mapping' => self::get_header_mappings( $csv_file ), ); $args = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class ); $importer = new $importer_class( $csv_file, $args ); $import = $importer->import(); return $import; } else { return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce' ) ); } } /** * Import sample products from WooCommerce sample CSV. * * @internal * @return WP_Error|WP_REST_Response */ public static function import_sample_products() { $sample_csv_file = Features::is_enabled( 'experimental-fashion-sample-products' ) ? WC_ABSPATH . 'sample-data/experimental_fashion_sample_9_products.csv' : WC_ABSPATH . 'sample-data/experimental_sample_9_products.csv'; $import = self::import_sample_products_from_csv( $sample_csv_file ); return rest_ensure_response( $import ); } /** * Creates a product from a template name passed in through the template_name param. * * @internal * @param WP_REST_Request $request Request data. * @return WP_REST_Response|WP_Error */ public static function create_product_from_template( $request ) { $template_name = basename( $request->get_param( 'template_name' ) ); $template_path = __DIR__ . '/Templates/' . $template_name . '_product.csv'; $template_path = apply_filters( 'woocommerce_product_template_csv_file_path', $template_path, $template_name ); $import = self::import_sample_products_from_csv( $template_path ); if ( is_wp_error( $import ) || ! is_array( $import['imported'] ) || 0 === count( $import['imported'] ) ) { return new \WP_Error( 'woocommerce_rest_product_creation_error', /* translators: %s is template name */ __( 'Sorry, creating the product with template failed.', 'woocommerce' ), array( 'status' => 500 ) ); } $product = wc_get_product( $import['imported'][0] ); $product->set_status( ProductStatus::AUTO_DRAFT ); $product->save(); return rest_ensure_response( array( 'id' => $product->get_id(), ) ); } /** * Get header mappings from CSV columns. * * @internal * @param string $file File path. * @return array Mapped headers. */ public static function get_header_mappings( $file ) { include_once WC_ABSPATH . 'includes/admin/importers/mappings/mappings.php'; $importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' ); $importer = new $importer_class( $file, array() ); $raw_headers = $importer->get_raw_keys(); $default_columns = wc_importer_default_english_mappings( array() ); $special_columns = wc_importer_default_special_english_mappings( array() ); $headers = array(); foreach ( $raw_headers as $key => $field ) { $index = $field; $headers[ $index ] = $field; if ( isset( $default_columns[ $field ] ) ) { $headers[ $index ] = $default_columns[ $field ]; } else { foreach ( $special_columns as $regex => $special_key ) { if ( preg_match( self::sanitize_special_column_name_regex( $regex ), $field, $matches ) ) { $headers[ $index ] = $special_key . $matches[1]; break; } } } } return $headers; } /** * Sanitize special column name regex. * * @internal * @param string $value Raw special column name. * @return string */ public static function sanitize_special_column_name_regex( $value ) { return '/' . str_replace( array( '%d', '%s' ), '(.*)', trim( quotemeta( $value ) ) ) . '/'; } /** * Returns a valid cover block with an image, if one exists, or background as a fallback. * * @internal * @param array $image Image to use for the cover block. Should contain a media ID and image URL. * @return string Block content. */ private static function get_homepage_cover_block( $image ) { $shop_url = wc_get_page_permalink( 'shop' ); if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) { return '<!-- wp:cover {"url":"' . esc_url( $image['url'] ) . '","id":' . intval( $image['id'] ) . ',"dimRatio":0} --> <div class="wp-block-cover" style="background-image:url(' . esc_url( $image['url'] ) . ')"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} --> <p class="has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:paragraph {"align":"center","textColor":"white"} --> <p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --> <div class="wp-block-buttons"><!-- wp:button --> <div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div> <!-- /wp:button --></div> <!-- /wp:buttons --></div></div> <!-- /wp:cover -->'; } return '<!-- wp:cover {"dimRatio":0} --> <div class="wp-block-cover"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} --> <p class="has-text-color has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:paragraph {"align":"center","textColor":"white"} --> <p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --> <div class="wp-block-buttons"><!-- wp:button --> <div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div> <!-- /wp:button --></div> <!-- /wp:buttons --></div></div> <!-- /wp:cover -->'; } /** * Returns a valid media block with an image, if one exists, or a uninitialized media block the user can set. * * @internal * @param array $image Image to use for the cover block. Should contain a media ID and image URL. * @param string $align If the image should be aligned to the left or right. * @return string Block content. */ private static function get_homepage_media_block( $image, $align = 'left' ) { $media_position = 'right' === $align ? '"mediaPosition":"right",' : ''; $css_class = 'right' === $align ? ' has-media-on-the-right' : ''; if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) { return '<!-- wp:media-text {' . $media_position . '"mediaId":' . intval( $image['id'] ) . ',"mediaType":"image"} --> <div class="wp-block-media-text alignwide' . $css_class . '""><figure class="wp-block-media-text__media"><img src="' . esc_url( $image['url'] ) . '" alt="" class="wp-image-' . intval( $image['id'] ) . '"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} --> <p class="has-large-font-size"></p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text -->'; } return '<!-- wp:media-text {' . $media_position . '} --> <div class="wp-block-media-text alignwide' . $css_class . '"><figure class="wp-block-media-text__media"></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} --> <p class="has-large-font-size"></p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text -->'; } /** * Returns a homepage template to be inserted into a post. A different template will be used depending on the number of products. * * @internal * @param int $post_id ID of the homepage template. * @return string Template contents. */ private static function get_homepage_template( $post_id ) { $products = wp_count_posts( 'product' ); if ( $products->publish >= 4 ) { $images = self::sideload_homepage_images( $post_id, 1 ); $image_1 = ! empty( $images[0] ) ? $images[0] : ''; $template = self::get_homepage_cover_block( $image_1 ) . ' <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Shop by Category', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:shortcode --> [product_categories number="0" parent="0"] <!-- /wp:shortcode --> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'New In', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-new {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Fan Favorites', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-top-rated {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'On Sale', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-on-sale {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Best Sellers', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-best-sellers {"columns":4} /--> '; /** * Modify the template/content of the default homepage. * * @param string $template The default homepage template. */ return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template ); } $images = self::sideload_homepage_images( $post_id, 3 ); $image_1 = ! empty( $images[0] ) ? $images[0] : ''; $image_2 = ! empty( $images[1] ) ? $images[1] : ''; $image_3 = ! empty( $images[2] ) ? $images[2] : ''; $template = self::get_homepage_cover_block( $image_1 ) . ' <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'New Products', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-new /--> ' . self::get_homepage_media_block( $image_1, 'right' ) . self::get_homepage_media_block( $image_2, 'left' ) . self::get_homepage_media_block( $image_3, 'right' ) . ' <!-- wp:woocommerce/featured-product /-->'; /** This filter is documented in src/API/OnboardingTasks.php. */ return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template ); } /** * Gets the possible industry images from the plugin folder for sideloading. If an image doesn't exist, other.jpg is used a fallback. * * @internal * @return array An array of images by industry. */ private static function get_available_homepage_images() { $industry_images = array(); $industries = OnboardingIndustries::get_allowed_industries(); foreach ( $industries as $industry_slug => $label ) { $industry_images[ $industry_slug ] = apply_filters( 'woocommerce_admin_onboarding_industry_image', WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/other-small.jpg', $industry_slug ); } return $industry_images; } /** * Uploads a number of images to a homepage template, depending on the selected industry from the profile wizard. * * @internal * @param int $post_id ID of the homepage template. * @param int $number_of_images The number of images that should be sideloaded (depending on how many media slots are in the template). * @return array An array of images that have been attached to the post. */ private static function sideload_homepage_images( $post_id, $number_of_images ) { $profile = get_option( OnboardingProfile::DATA_OPTION, array() ); $images_to_sideload = array(); $available_images = self::get_available_homepage_images(); require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; if ( ! empty( $profile['industry'] ) ) { foreach ( $profile['industry'] as $selected_industry ) { if ( is_string( $selected_industry ) ) { $industry_slug = $selected_industry; } elseif ( is_array( $selected_industry ) && ! empty( $selected_industry['slug'] ) ) { $industry_slug = $selected_industry['slug']; } else { continue; } // Capture the first industry for use in our minimum images logic. $first_industry = isset( $first_industry ) ? $first_industry : $industry_slug; $images_to_sideload[] = ! empty( $available_images[ $industry_slug ] ) ? $available_images[ $industry_slug ] : $available_images['other']; } } // Make sure we have at least {$number_of_images} images. if ( count( $images_to_sideload ) < $number_of_images ) { for ( $i = count( $images_to_sideload ); $i < $number_of_images; $i++ ) { // Fill up missing image slots with the first selected industry, or other. $industry = isset( $first_industry ) ? $first_industry : 'other'; $images_to_sideload[] = empty( $available_images[ $industry ] ) ? $available_images['other'] : $available_images[ $industry ]; } } $already_sideloaded = array(); $images_for_post = array(); foreach ( $images_to_sideload as $image ) { // Avoid uploading two of the same image, if an image is repeated. if ( ! empty( $already_sideloaded[ $image ] ) ) { $images_for_post[] = $already_sideloaded[ $image ]; continue; } $sideload_id = \media_sideload_image( $image, $post_id, null, 'id' ); if ( ! is_wp_error( $sideload_id ) ) { $sideload_url = wp_get_attachment_url( $sideload_id ); $already_sideloaded[ $image ] = array( 'id' => $sideload_id, 'url' => $sideload_url, ); $images_for_post[] = $already_sideloaded[ $image ]; } } return $images_for_post; } /** * Create a homepage from a template. * * @return WP_Error|array */ public static function create_homepage() { $post_id = wp_insert_post( array( 'post_title' => __( 'Homepage', 'woocommerce' ), 'post_type' => 'page', 'post_status' => 'publish', 'post_content' => '', // Template content is updated below, so images can be attached to the post. ) ); if ( ! is_wp_error( $post_id ) && 0 < $post_id ) { $template = self::get_homepage_template( $post_id ); wp_update_post( array( 'ID' => $post_id, 'post_content' => $template, ) ); update_option( 'show_on_front', 'page' ); update_option( 'page_on_front', $post_id ); update_option( 'woocommerce_onboarding_homepage_post_id', $post_id ); // Use the full width template on stores using Storefront. if ( 'storefront' === get_stylesheet() ) { update_post_meta( $post_id, '_wp_page_template', 'template-fullwidth.php' ); } return array( 'status' => 'success', 'message' => __( 'Homepage created', 'woocommerce' ), 'post_id' => $post_id, 'edit_post_link' => htmlspecialchars_decode( get_edit_post_link( $post_id ) ), ); } else { return $post_id; } } /** * Get the query params for task lists. * * @return array */ public function get_task_list_params() { $params = array(); $params['ids'] = array( 'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => TaskLists::get_list_ids(), 'type' => 'string', ), ); $params['extended_tasks'] = array( 'description' => __( 'List of extended deprecated tasks from the client side filter.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => function( $param, $request, $key ) { $has_valid_keys = true; foreach ( $param as $task ) { if ( $has_valid_keys ) { $has_valid_keys = array_key_exists( 'list_id', $task ) && array_key_exists( 'id', $task ); } } return $has_valid_keys; }, ); return $params; } /** * Get the onboarding tasks. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_tasks( $request ) { $extended_tasks = $request->get_param( 'extended_tasks' ); $task_list_ids = $request->get_param( 'ids' ); TaskLists::maybe_add_extended_tasks( $extended_tasks ); $lists = is_array( $task_list_ids ) && count( $task_list_ids ) > 0 ? TaskLists::get_lists_by_ids( $task_list_ids ) : TaskLists::get_lists(); $json = array_map( function( $list ) { return $list->sort_tasks()->get_json(); }, $lists ); return rest_ensure_response( array_values( apply_filters( 'woocommerce_admin_onboarding_tasks', $json ) ) ); } /** * Dismiss a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function dismiss_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_dismissable' => true, ) ); } if ( ! $task || ! $task->is_dismissable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->dismiss(); return rest_ensure_response( $task->get_json() ); } /** * Undo dismissal of a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function undo_dismiss_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_dismissable' => true, ) ); } if ( ! $task || ! $task->is_dismissable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->undo_dismiss(); return rest_ensure_response( $task->get_json() ); } /** * Snooze an onboarding task. * * @deprecated 7.8.0 snooze task is deprecated. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function snooze_task( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); $task_id = $request->get_param( 'id' ); $task_list_id = $request->get_param( 'task_list_id' ); $duration = $request->get_param( 'duration' ); $task = TaskLists::get_task( $task_id, $task_list_id ); if ( ! $task && $task_id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $task_id, 'is_snoozeable' => true, ) ); } if ( ! $task || ! $task->is_snoozeable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->snooze( isset( $duration ) ? $duration : 'day' ); return rest_ensure_response( $task->get_json() ); } /** * Undo snooze of a single task. * * @deprecated 7.8.0 undo snooze task is deprecated. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function undo_snooze_task( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_snoozeable' => true, ) ); } if ( ! $task || ! $task->is_snoozeable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->undo_snooze(); return rest_ensure_response( $task->get_json() ); } /** * Hide a task list. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function hide_task_list( $request ) { $id = $request->get_param( 'id' ); $task_list = TaskLists::get_list( $id ); if ( ! $task_list ) { return new \WP_Error( 'woocommerce_rest_invalid_task_list', __( 'Sorry, that task list was not found', 'woocommerce' ), array( 'status' => 404, ) ); } $update = $task_list->hide(); $json = $task_list->get_json(); return rest_ensure_response( $json ); } /** * Unhide a task list. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function unhide_task_list( $request ) { $id = $request->get_param( 'id' ); $task_list = TaskLists::get_list( $id ); if ( ! $task_list ) { return new \WP_Error( 'woocommerce_tasks_invalid_task_list', __( 'Sorry, that task list was not found', 'woocommerce' ), array( 'status' => 404, ) ); } $update = $task_list->unhide(); $json = $task_list->get_json(); return rest_ensure_response( $json ); } /** * Action a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function action_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, ) ); } if ( ! $task ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->mark_actioned(); return rest_ensure_response( $task->get_json() ); } } NoteActions.php 0000777 00000004621 15252174340 0007520 0 ustar 00 <?php /** * REST API Admin Note Action controller * * Handles requests to the admin note action endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\Notes\Notes as NotesFactory; /** * REST API Admin Note Action controller class. * * @internal * @extends WC_REST_CRUD_Controller */ class NoteActions extends Notes { /** * Register the routes for admin notes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<note_id>[\d-]+)/action/(?P<action_id>[\d-]+)', array( 'args' => array( 'note_id' => array( 'description' => __( 'Unique ID for the Note.', 'woocommerce' ), 'type' => 'integer', ), 'action_id' => array( 'description' => __( 'Unique ID for the Note Action.', 'woocommerce' ), 'type' => 'integer', ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'trigger_note_action' ), // @todo - double check these permissions for taking note actions. 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Trigger a note action. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function trigger_note_action( $request ) { $note = NotesFactory::get_note( $request->get_param( 'note_id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $note->set_is_read( true ); $note->save(); $triggered_action = NotesFactory::get_action_by_id( $note, $request->get_param( 'action_id' ) ); if ( ! $triggered_action ) { return new \WP_Error( 'woocommerce_note_action_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $triggered_note = NotesFactory::trigger_note_action( $note, $triggered_action ); $data = $triggered_note->get_data(); $data = $this->prepare_item_for_response( $data, $request ); $data = $this->prepare_response_for_collection( $data ); return rest_ensure_response( $data ); } } Marketing.php 0000777 00000011540 15252174340 0007211 0 ustar 00 <?php /** * REST API Marketing Controller * * Handles requests to /marketing. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs; use Automattic\WooCommerce\Admin\Features\MarketingRecommendations\Init as MarketingRecommendationsInit; defined( 'ABSPATH' ) || exit; /** * Marketing Controller. * * @internal * @extends WC_REST_Data_Controller */ class Marketing extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/recommended', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_recommended_plugins' ), 'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ), 'args' => array( 'per_page' => $this->get_collection_params()['per_page'], 'category' => array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/knowledge-base', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_knowledge_base_posts' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => array( 'category' => array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/misc-recommendations', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_misc_recommendations' ), 'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to install plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_recommended_plugins_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_recommended_plugins( $request ) { // Default to marketing category (if no category set). $category = ( ! empty( $request->get_param( 'category' ) ) ) ? $request->get_param( 'category' ) : 'marketing'; $all_plugins = MarketingRecommendationsInit::get_recommended_plugins(); $valid_plugins = []; $per_page = $request->get_param( 'per_page' ); foreach ( $all_plugins as $plugin ) { // default to marketing if 'categories' is empty on the plugin object (support for legacy api while testing). $plugin_categories = ( ! empty( $plugin['categories'] ) ) ? $plugin['categories'] : [ 'marketing' ]; if ( ! PluginsHelper::is_plugin_installed( $plugin['plugin'] ) && in_array( $category, $plugin_categories, true ) ) { $valid_plugins[] = $plugin; } } return rest_ensure_response( array_slice( $valid_plugins, 0, $per_page ) ); } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_knowledge_base_posts( $request ) { /** * MarketingSpecs class. * * @var MarketingSpecs $marketing_specs */ $marketing_specs = wc_get_container()->get( MarketingSpecs::class ); $category = $request->get_param( 'category' ); return rest_ensure_response( $marketing_specs->get_knowledge_base_posts( $category ) ); } /** * Return misc recommendations. * * @param \WP_REST_Request $request Request data. * * @since 9.5.0 * * @return \WP_Error|\WP_REST_Response */ public function get_misc_recommendations( $request ) { $misc_recommendations = MarketingRecommendationsInit::get_misc_recommendations(); return rest_ensure_response( $misc_recommendations ); } } Notice.php 0000777 00000004605 15252174340 0006515 0 ustar 00 <?php /** * REST API Notice controller * * Handles requests to /notice/ */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Notice Controller. * * @internal * @extends WC_REST_Data_Controller */ class Notice extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'notice'; /** * Register the routes for admin notes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/dismiss', array( array( 'methods' => 'POST', 'callback' => array( $this, 'dissmiss_notice' ), 'permission_callback' => array( $this, 'get_permission' ), ), ) ); } /** * Save notice dismiss information in user meta. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function dissmiss_notice( $request ) { if ( ! isset( $request['dismiss_notice_nonce'] ) || ! wp_verify_nonce( $request['dismiss_notice_nonce'], 'dismiss_notice' ) ) { return new WP_Error( 'unauthorized', 'Invalid nonce.', array( 'status' => 401 ) ); } $notice_id = isset( $request['notice_id'] ) ? sanitize_text_field( wp_unslash( $request['notice_id'] ) ) : ''; $dismissed = false; switch ( $notice_id ) { case 'woo-subscription-expired-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_EXPIRED_SUBS_NOTICE, time() ); $dismissed = true; break; case 'woo-subscription-expiring-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_EXPIRING_SUBS_NOTICE, time() ); $dismissed = true; break; case 'woo-disconnect-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_DISCONNECT_NOTICE, time() ); $dismissed = true; break; case 'woo-connect-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_CONNECT_NOTICE, time() ); $dismissed = true; break; } return rest_ensure_response( array( 'success' => $dismissed, ) ); } /** * Check user has the necessary permissions to perform this action. * * @return bool */ public function get_permission(): bool { return current_user_can( 'manage_woocommerce' ); } } ProductsLowInStock.php 0000777 00000043102 15252174340 0011047 0 ustar 00 <?php /** * REST API ProductsLowInStock Controller * * Handles request to /products/low-in-stock */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Enums\ProductType; defined( 'ABSPATH' ) || exit; /** * ProductsLowInStock controller. * * @internal * @extends WC_REST_Products_Controller */ final class ProductsLowInStock extends \WC_REST_Products_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, 'products/low-in-stock', array( 'args' => array(), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, 'products/count-low-in-stock', array( 'args' => array(), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_low_in_stock_count' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_low_in_stock_count_params(), ), 'schema' => array( $this, 'get_low_in_stock_count_schema' ), ) ); } /** * Return # of low in stock count. * * @param WP_REST_Request $request request object. * * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_low_in_stock_count( $request ) { $status = $request->get_param( 'status' ); $low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $sidewide_stock_threshold_only = $this->is_using_sitewide_stock_threshold_only( $low_stock_threshold ); $total_results = $this->get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ); $response = rest_ensure_response( array( 'total' => $total_results ) ); $response->header( 'X-WP-Total', $total_results ); $response->header( 'X-WP-TotalPages', 0 ); return $response; } /** * Get low in stock products. * * @param WP_REST_Request $request request object. * * @return WP_REST_Response|WP_ERROR */ public function get_items( $request ) { $query_results = $this->get_low_in_stock_products( $request->get_param( 'page' ), $request->get_param( 'per_page' ), $request->get_param( 'status' ) ); // set images and attributes. $query_results['results'] = array_map( function ( $query_result ) { $product = wc_get_product( $query_result ); $query_result->images = $this->get_images( $product ); $query_result->attributes = $this->get_attributes( $product ); return $query_result; }, $query_results['results'] ); // set last_order_date. $query_results['results'] = $this->set_last_order_date( $query_results['results'] ); // convert the post data to the expected API response for the backward compatibility. $query_results['results'] = array_map( array( $this, 'transform_post_to_api_response' ), $query_results['results'] ); $response = rest_ensure_response( array_values( $query_results['results'] ) ); $response->header( 'X-WP-Total', $query_results['total'] ); $response->header( 'X-WP-TotalPages', $query_results['pages'] ); return $response; } /** * Set the last order date for each data. * * @param array $results query result from get_low_in_stock_products. * * @return mixed */ protected function set_last_order_date( $results = array() ) { global $wpdb; if ( 0 === count( $results ) ) { return $results; } $wheres = array(); foreach ( $results as $result ) { 'product_variation' === $result->post_type ? array_push( $wheres, "(product_id={$result->post_parent} and variation_id={$result->ID})" ) : array_push( $wheres, "product_id={$result->ID}" ); } count( $wheres ) ? $where_clause = implode( ' or ', $wheres ) : $where_clause = $wheres[0]; $product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $query_string = " select product_id, variation_id, MAX( wc_order_product_lookup.date_created ) AS last_order_date from {$product_lookup_table} wc_order_product_lookup where {$where_clause} group by product_id order by date_created desc "; // phpcs:ignore -- ignore prepare() warning as we're not using any user input here. $last_order_dates = $wpdb->get_results( $query_string ); $last_order_dates_index = array(); // Make an index with product_id_variation_id as a key // so that it can be referenced back without looping the whole array. foreach ( $last_order_dates as $last_order_date ) { $last_order_dates_index[ $last_order_date->product_id . '_' . $last_order_date->variation_id ] = $last_order_date; } foreach ( $results as &$result ) { 'product_variation' === $result->post_type ? $index_key = $result->post_parent . '_' . $result->ID : $index_key = $result->ID . '_' . $result->post_parent; if ( isset( $last_order_dates_index[ $index_key ] ) ) { $result->last_order_date = $last_order_dates_index[ $index_key ]->last_order_date; } } return $results; } /** * Get low in stock products data. * * @param int $page current page. * @param int $per_page items per page. * @param string $status post status. * * @return array */ protected function get_low_in_stock_products( $page = 1, $per_page = 1, $status = ProductStatus::PUBLISH ) { global $wpdb; $offset = ( $page - 1 ) * $per_page; $low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $sidewide_stock_threshold_only = $this->is_using_sitewide_stock_threshold_only( $low_stock_threshold ); $query_string = $this->get_query( $sidewide_stock_threshold_only ); $query_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $query_string, $status, $low_stock_threshold, $offset, $per_page ), OBJECT_K ); $total_results = $this->get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ); return array( 'results' => $query_results, 'total' => (int) $total_results, 'pages' => (int) ceil( $total_results / (int) $per_page ), ); } /** * Get the count of low in stock products. * * @param bool $sidewide_stock_threshold_only Boolean to check if the store is using sitewide stock threshold only. * @param string $status Post status. * @param int $low_stock_threshold Low stock threshold. * * @return int */ protected function get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ) { global $wpdb; if ( $sidewide_stock_threshold_only ) { $count_query_string = $this->get_count_query( $sidewide_stock_threshold_only ); $count_query_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_string, $status, $low_stock_threshold ), ); return (int) $count_query_results[0]->total; } // Split the query into two queries, one for products with a custom stock threshold and one for products without a custom stock threshold. // Splitting the queries also speeds up the query. $count_query_with_custom_stock_threshold_string = $this->get_products_with_custom_stock_threshold_count_query_str(); $count_query_without_custom_stock_threshold_string = $this->get_products_without_custom_stock_threshold_count_query_str(); $count_query_with_custom_stock_threshold_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_with_custom_stock_threshold_string, $status ), ); $count_query_without_custom_stock_threshold_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_without_custom_stock_threshold_string, $status, $low_stock_threshold ), ); return (int) $count_query_with_custom_stock_threshold_results[0]->total + (int) $count_query_without_custom_stock_threshold_results[0]->total; } /** * Check to see if store is using sitewide threshold only. Meaning that it does not have any custom * stock threshold for a product. * * @param int|null $low_stock_threshold Low stock threshold. * @return bool */ protected function is_using_sitewide_stock_threshold_only( $low_stock_threshold = null ) { global $wpdb; $query_string = " select count(*) as total from {$wpdb->postmeta} where meta_key='_low_stock_amount' AND meta_value > '' "; $args = array(); if ( $low_stock_threshold ) { $query_string .= ' AND meta_value != %d'; $args[] = $low_stock_threshold; } // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $count = $wpdb->get_var( $wpdb->prepare( $query_string, $args ) ); return 0 === (int) $count; } /** * Transform post object to expected API response. * * @param object $query_result a row of query result from get_low_in_stock_products(). * * @return array */ protected function transform_post_to_api_response( $query_result ) { $low_stock_amount = null; if ( isset( $query_result->low_stock_amount ) ) { $low_stock_amount = (int) $query_result->low_stock_amount; } if ( ! isset( $query_result->last_order_date ) ) { $query_result->last_order_date = null; } return array( 'id' => (int) $query_result->ID, 'images' => $query_result->images, 'attributes' => $query_result->attributes, 'low_stock_amount' => $low_stock_amount, 'last_order_date' => wc_rest_prepare_date_response( $query_result->last_order_date ), 'name' => $query_result->post_title, 'parent_id' => (int) $query_result->post_parent, 'stock_quantity' => (int) $query_result->stock_quantity, 'type' => 'product_variation' === $query_result->post_type ? ProductType::VARIATION : ProductType::SIMPLE, ); } /** * Return a query string for low in stock products. * The query string includes the following replacement strings: * - :selects * - :postmeta_join * - :postmeta_wheres * - :orderAndLimit * * @param array $replacements of replacement strings. * * @return string */ private function get_base_query( $replacements = array() ) { global $wpdb; $query = " SELECT :selects FROM {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup LEFT JOIN {$wpdb->posts} wp_posts ON wp_posts.ID = wc_product_meta_lookup.product_id :postmeta_join WHERE wp_posts.post_type IN ('product', 'product_variation') AND wp_posts.post_status = %s AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status IN('instock', 'outofstock') :postmeta_wheres :orderAndLimit "; return strtr( $query, $replacements ); } /** * Add sitewide stock query string to base query string. * * @param string $query Base query string. * * @return string */ private function add_sitewide_stock_query_str( $query ) { global $wpdb; $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount'", 'wheres' => "AND ( ( meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST( meta.meta_value AS SIGNED ) ) OR ( ( meta.meta_value IS NULL OR meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= %d ) )", ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Get a query string for products with a custom stock threshold. * * @return string */ private function get_products_with_custom_stock_threshold_count_query_str() { global $wpdb; $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount' AND meta.meta_value > ''", 'wheres' => 'AND wc_product_meta_lookup.stock_quantity <= CAST(meta.meta_value AS SIGNED)', ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Get a query string for products without a custom stock threshold. * * @return string */ private function get_products_without_custom_stock_threshold_count_query_str() { global $wpdb; $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount' AND meta.meta_value > ''", 'wheres' => 'AND meta.post_id IS NULL AND wc_product_meta_lookup.stock_quantity <= %d', ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Generate a query. * * @param bool $sitewide_only generates a query for sitewide low stock threshold only query. * * @return string */ protected function get_query( $sitewide_only = false ) { $query = $this->get_base_query( array( ':selects' => 'wp_posts.*, :postmeta_select wc_product_meta_lookup.stock_quantity', ':orderAndLimit' => 'order by wc_product_meta_lookup.product_id DESC limit %d, %d', ) ); if ( ! $sitewide_only ) { return $this->add_sitewide_stock_query_str( $query ); } return strtr( $query, array( ':postmeta_select' => '', ':postmeta_join' => '', ':postmeta_wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d', ) ); } /** * Generate a count query. * * @param bool $sitewide_only generates a query for sitewide low stock threshold only query. * * @return string */ protected function get_count_query( $sitewide_only = false ) { $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); if ( ! $sitewide_only ) { return $this->add_sitewide_stock_query_str( $query ); } return strtr( $query, array( ':postmeta_select' => '', ':postmeta_join' => '', ':postmeta_wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d', ) ); } /** * Get the query params for collections of attachments. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['status'] = array( 'default' => 'publish', 'description' => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_merge( array_keys( get_post_statuses() ), array( ProductStatus::FUTURE ) ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the query params for collections for /count-low-in-stock endpoint. * * @return array */ public function get_low_in_stock_count_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['status'] = array( 'default' => 'publish', 'description' => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_merge( array_keys( get_post_statuses() ), array( ProductStatus::FUTURE ) ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema for /count-low-in-stock response. * * @return array */ public function get_low_in_stock_count_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Count Low in Stock Items', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'total' => 'integer', ), ), ); } } MarketingChannels.php 0000777 00000013366 15252174340 0010675 0 ustar 00 <?php /** * REST API MarketingChannels Controller * * Handles requests to /marketing/channels. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingChannelInterface; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingChannels Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingChannels extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/channels'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to view marketing channels. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); $channels = $marketing_channels_service->get_registered_channels(); $responses = []; foreach ( $channels as $item ) { $response = $this->prepare_item_for_response( $item, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param MarketingChannelInterface $item WordPress representation of the item. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function prepare_item_for_response( $item, $request ) { $data = [ 'slug' => $item->get_slug(), 'is_setup_completed' => $item->is_setup_completed(), 'settings_url' => $item->get_setup_url(), 'name' => $item->get_name(), 'description' => $item->get_description(), 'product_listings_status' => $item->get_product_listings_status(), 'errors_count' => $item->get_errors_count(), 'icon' => $item->get_icon_url(), ]; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_channel', 'type' => 'object', 'properties' => [ 'slug' => [ 'description' => __( 'Unique identifier string for the marketing channel extension, also known as the plugin slug.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the marketing channel.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Description of the marketing channel.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon' => [ 'description' => __( 'Path to the channel icon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'is_setup_completed' => [ 'type' => 'boolean', 'description' => __( 'Whether or not the marketing channel is set up.', 'woocommerce' ), 'context' => [ 'view' ], 'readonly' => true, ], 'settings_url' => [ 'description' => __( 'URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'product_listings_status' => [ 'description' => __( 'Status of the marketing channel\'s product listings.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'errors_count' => [ 'description' => __( 'Number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ]; return $this->add_additional_fields_schema( $schema ); } } SettingOptions.php 0000777 00000001556 15252174340 0010267 0 ustar 00 <?php /** * REST API Setting Options Controller * * Handles requests to /settings/{option} */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * Setting Options controller. * * @internal * @extends WC_REST_Setting_Options_Controller */ class SettingOptions extends \WC_REST_Setting_Options_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Invalidates API cache when updating settings options. * * @param WP_REST_Request $request Full details about the request. * @return array Of WP_Error or WP_REST_Response. */ public function batch_items( $request ) { // Invalidate the API cache. ReportsCache::invalidate(); // Process the request. return parent::batch_items( $request ); } } Data.php 0000777 00000001653 15252174340 0006145 0 ustar 00 <?php /** * REST API Data Controller * * Handles requests to /data */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data controller. * * @internal * @extends WC_REST_Data_Controller */ class Data extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Return the list of data resources. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { $response = parent::get_items( $request ); $response->data[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( (object) array( 'slug' => 'download-ips', 'description' => __( 'An endpoint used for searching download logs for a specific IP address.', 'woocommerce' ), ), $request ) ); return $response; } } Init.php 0000777 00000025107 15252174340 0006177 0 ustar 00 <?php /** * REST API bootstrap. */ namespace Automattic\WooCommerce\Admin\API; use AllowDynamicProperties; use Automattic\WooCommerce\Admin\Features\Features; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Utilities\RestApiUtil; /** * Init class. * * @internal */ #[AllowDynamicProperties] class Init { /** * The single instance of the class. * * @var object */ protected static $instance = null; /** * Get class instance. * * @return object Instance. */ final public static function instance() { if ( null === static::$instance ) { static::$instance = new static(); } return static::$instance; } /** * Bootstrap REST API. */ public function __construct() { // Hook in data stores. add_filter( 'woocommerce_data_stores', array( __CLASS__, 'add_data_stores' ) ); // REST API extensions init. add_action( 'rest_api_init', array( $this, 'rest_api_init' ) ); // Add currency symbol to orders endpoint response. add_filter( 'woocommerce_rest_prepare_shop_order_object', array( __CLASS__, 'add_currency_symbol_to_order_response' ) ); include_once WC_ABSPATH . 'includes/admin/class-wc-admin-upload-downloadable-product.php'; } /** * Initialize the API namespaces under WooCommerce Admin. * * @return void */ public function rest_api_init() { if ( wc_rest_should_load_namespace( 'wc-admin' ) ) { $this->rest_api_init_wc_admin(); } $rest_api_util = wc_get_container()->get( RestApiUtil::class ); $rest_api_util->lazy_load_namespace( 'wc-analytics', array( $this, 'rest_api_init_wc_analytics' ) ); if ( Features::is_enabled( 'launch-your-store' ) ) { $controller = 'Automattic\WooCommerce\Admin\API\LaunchYourStore'; $this->$controller = new $controller(); $this->$controller->register_routes(); } } /** * Load the wc-admin namespace controllers. * * @return void */ public function rest_api_init_wc_admin() { $controllers = array( 'Automattic\WooCommerce\Admin\API\Notice', 'Automattic\WooCommerce\Admin\API\Features', 'Automattic\WooCommerce\Admin\API\Experiments', 'Automattic\WooCommerce\Admin\API\Marketing', 'Automattic\WooCommerce\Admin\API\MarketingOverview', 'Automattic\WooCommerce\Admin\API\MarketingRecommendations', 'Automattic\WooCommerce\Admin\API\MarketingChannels', 'Automattic\WooCommerce\Admin\API\MarketingCampaigns', 'Automattic\WooCommerce\Admin\API\MarketingCampaignTypes', 'Automattic\WooCommerce\Admin\API\Options', 'Automattic\WooCommerce\Admin\API\Settings', 'Automattic\WooCommerce\Admin\API\PaymentGatewaySuggestions', 'Automattic\WooCommerce\Admin\API\Themes', 'Automattic\WooCommerce\Admin\API\Plugins', 'Automattic\WooCommerce\Admin\API\OnboardingFreeExtensions', 'Automattic\WooCommerce\Admin\API\OnboardingProductTypes', 'Automattic\WooCommerce\Admin\API\OnboardingProfile', 'Automattic\WooCommerce\Admin\API\OnboardingTasks', 'Automattic\WooCommerce\Admin\API\OnboardingThemes', 'Automattic\WooCommerce\Admin\API\OnboardingPlugins', 'Automattic\WooCommerce\Admin\API\OnboardingProducts', 'Automattic\WooCommerce\Admin\API\MobileAppMagicLink', 'Automattic\WooCommerce\Admin\API\ShippingPartnerSuggestions', ); if ( ! did_action( 'woocommerce_admin_rest_controllers' ) ) { /** * Filter for the WooCommerce Admin REST controllers. * * Admin and Analytics controllers were originally loaded in one place. However, with attempts to dynamically * load namespaces based on context, these were split up. However, to maintain backward compatibility, we * must run this hook if either namespace is loaded because extensions could be targeting either namespace. * * @param array $controllers List of rest API controllers. * * @since 3.5.0 */ $controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers ); if ( ! is_array( $controllers ) ) { return; } } $controllers = array_values( array_unique( $controllers ) ); foreach ( $controllers as $controller ) { if ( is_string( $controller ) ) { $this->$controller = new $controller(); $this->$controller->register_routes(); } } } /** * Load the wc-analytics namespace controllers. * * @return void */ public function rest_api_init_wc_analytics() { // Controllers in wc-analytics namespace, but loaded irrespective of analytics feature value. $controllers = array( 'Automattic\WooCommerce\Admin\API\Notes', 'Automattic\WooCommerce\Admin\API\NoteActions', 'Automattic\WooCommerce\Admin\API\Coupons', 'Automattic\WooCommerce\Admin\API\Data', 'Automattic\WooCommerce\Admin\API\DataCountries', 'Automattic\WooCommerce\Admin\API\DataDownloadIPs', 'Automattic\WooCommerce\Admin\API\Orders', 'Automattic\WooCommerce\Admin\API\Products', 'Automattic\WooCommerce\Admin\API\ProductAttributes', 'Automattic\WooCommerce\Admin\API\ProductAttributeTerms', 'Automattic\WooCommerce\Admin\API\ProductCategories', 'Automattic\WooCommerce\Admin\API\ProductVariations', 'Automattic\WooCommerce\Admin\API\ProductReviews', 'Automattic\WooCommerce\Admin\API\ProductsLowInStock', 'Automattic\WooCommerce\Admin\API\SettingOptions', 'Automattic\WooCommerce\Admin\API\Taxes', ); $analytics_controllers = array(); if ( Features::is_enabled( 'analytics' ) ) { $analytics_controllers = array( 'Automattic\WooCommerce\Admin\API\Customers', 'Automattic\WooCommerce\Admin\API\Leaderboards', 'Automattic\WooCommerce\Admin\API\Reports\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Import\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Export\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Products\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Controller', ); if ( Features::is_enabled( 'analytics-scheduled-import' ) ) { $analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\AnalyticsImports'; } // The performance indicators controllerq must be registered last, after other /stats endpoints have been registered. $analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators\Controller'; } $controllers = array_merge( $analytics_controllers, $controllers ); if ( ! did_action( 'woocommerce_admin_rest_controllers' ) ) { /** * Filter for the WooCommerce Admin REST controllers. * * @param array $controllers List of rest API controllers. * * @since 3.5.0 * * @see self::rest_api_init_wc_admin() for extended documentation. */ $controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers ); if ( ! is_array( $controllers ) ) { return; } } $controllers = array_values( array_unique( $controllers ) ); foreach ( $controllers as $controller ) { if ( is_string( $controller ) ) { $this->$controller = new $controller(); $this->$controller->register_routes(); } } } /** * Adds data stores. * * @internal * @param array $data_stores List of data stores. * @return array */ public static function add_data_stores( $data_stores ) { return array_merge( $data_stores, array( 'report-revenue-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore', 'report-orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore', 'report-orders-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore', 'report-products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore', 'report-variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore', 'report-products-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore', 'report-variations-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\DataStore', 'report-categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore', 'report-taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore', 'report-taxes-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore', 'report-coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore', 'report-coupons-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore', 'report-downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore', 'report-downloads-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore', 'admin-note' => 'Automattic\WooCommerce\Admin\Notes\DataStore', 'report-customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore', 'report-customers-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore', 'report-stock-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore', ) ); } /** * Add the currency symbol (in addition to currency code) to each Order * object in REST API responses. For use in formatAmount(). * * @internal * @param WP_REST_Response $response REST response object. * @returns WP_REST_Response */ public static function add_currency_symbol_to_order_response( $response ) { $response_data = $response->get_data(); $currency_code = $response_data['currency']; $currency_symbol = get_woocommerce_currency_symbol( $currency_code ); $response_data['currency_symbol'] = html_entity_decode( $currency_symbol ); $response->set_data( $response_data ); return $response; } } OnboardingProfile.php 0000777 00000044601 15252174340 0010677 0 ustar 00 <?php /** * REST API Onboarding Profile Controller * * Handles requests to /onboarding/profile */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile as Profile; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts; use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager; use WP_Error; use WP_REST_Request; use WP_REST_Response; /** * Onboarding Profile controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProfile extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/profile'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); // This endpoint is experimental. For internal use only. register_rest_route( $this->namespace, '/' . $this->rest_base . '/experimental_get_email_prefill', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_email_prefill' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/progress', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_profile_progress' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/progress/core-profiler/complete', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'core_profiler_step_complete' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'step' => array( 'required' => true, 'type' => 'string', 'description' => __( 'The Core Profiler step to mark as complete.', 'woocommerce' ), 'enum' => array( 'intro-opt-in', 'skip-guided-setup', 'user-profile', 'business-info', 'plugins', 'intro-builder', 'skip-guided-setup', ), ), ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update-store-currency-and-measurement-units', array( array( 'methods' => 'POST', 'callback' => array( $this, 'update_store_currency_and_measurement_units' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'country_code' => array( 'description' => __( 'Country code.', 'woocommerce' ), 'type' => 'string', 'required' => true, ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether a given request has permission to edit onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return all onboarding profile data. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php'; $onboarding_data = get_option( Profile::DATA_OPTION, array() ); $onboarding_data['industry'] = isset( $onboarding_data['industry'] ) ? $this->filter_industries( $onboarding_data['industry'] ) : null; $item_schema = $this->get_item_schema(); $items = array(); foreach ( $item_schema['properties'] as $key => $property_schema ) { $items[ $key ] = isset( $onboarding_data[ $key ] ) ? $onboarding_data[ $key ] : null; } $wccom_auth = \WC_Helper_Options::get( 'auth' ); $items['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true; $item = $this->prepare_item_for_response( $items, $request ); $data = $this->prepare_response_for_collection( $item ); return rest_ensure_response( $data ); } /** * Filter the industries. * * @param array $industries List of industries. * @return array */ protected function filter_industries( $industries ) { /** * Filter the list of industries. * * @since 6.5.0 * @param array $industries List of industries. */ return apply_filters( 'woocommerce_admin_onboarding_industries', $industries ); } /** * Update onboarding profile data. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function update_items( $request ) { $params = $request->get_json_params(); $query_args = $this->prepare_objects_query( $params ); $onboarding_data = (array) get_option( Profile::DATA_OPTION, array() ); $profile_data = array_merge( $onboarding_data, $query_args ); update_option( Profile::DATA_OPTION, $profile_data ); /** * Fires when onboarding profile data is updated via the REST API. * * @since 6.5.0 * @param array $onboarding_data Previous onboarding data. * @param array $query_args New data being set. */ do_action( 'woocommerce_onboarding_profile_data_updated', $onboarding_data, $query_args ); $result = array( 'status' => 'success', 'message' => __( 'Onboarding profile data has been updated.', 'woocommerce' ), ); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Returns a default email to be pre-filled in OBW. Prioritizes Jetpack if connected, * otherwise will default to WordPress general settings. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_email_prefill( $request ) { $result = array( 'email' => '', ); // Attempt to get email from Jetpack. if ( class_exists( Jetpack_Connection_Manager::class ) ) { $jetpack_connection_manager = new Jetpack_Connection_Manager(); if ( $jetpack_connection_manager->is_active() ) { $jetpack_user = $jetpack_connection_manager->get_connected_user_data(); $result['email'] = $jetpack_user['email']; } } // Attempt to get email from WordPress general settings. if ( empty( $result['email'] ) ) { $result['email'] = get_option( 'admin_email' ); } return rest_ensure_response( $result ); } /** * Mark a core profiler step as complete. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function core_profiler_step_complete( $request ) { $json = $request->get_json_params(); $step = $json['step']; $onboarding_progress = (array) get_option( Profile::PROGRESS_OPTION, array() ); if ( ! isset( $onboarding_progress['core_profiler_completed_steps'] ) ) { $onboarding_progress['core_profiler_completed_steps'] = array(); } $onboarding_progress['core_profiler_completed_steps'][ $step ] = array( 'completed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), ); update_option( Profile::PROGRESS_OPTION, $onboarding_progress ); /** * Fires when a core profiler step is completed. * * @since 6.5.0 * @param string $step The completed step name. */ do_action( 'woocommerce_core_profiler_step_complete', $step ); $response_data = array( 'results' => $onboarding_progress, 'status' => 'success', ); $response = rest_ensure_response( $response_data ); return $response; } /** * Get the onboarding profile progress. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_profile_progress( $request ) { $onboarding_progress = (array) get_option( Profile::PROGRESS_OPTION, array() ); return rest_ensure_response( $onboarding_progress ); } /** * Update store's currency and measurement units. * Requires 'country' code to be passed in the request. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function update_store_currency_and_measurement_units( WP_REST_Request $request ) { $country_code = $request->get_param( 'country_code' ); $locale_info = include WC()->plugin_path() . '/i18n/locale-info.php'; if ( empty( $country_code ) || ! isset( $locale_info[ $country_code ] ) ) { return new WP_Error( 'woocommerce_rest_invalid_country_code', __( 'Invalid country code.', 'woocommerce' ), array( 'status' => 400 ) ); } $country_info = $locale_info[ $country_code ]; $currency_settings = array( 'woocommerce_currency' => $country_info['currency_code'], 'woocommerce_currency_pos' => $country_info['currency_pos'], 'woocommerce_price_thousand_sep' => $country_info['thousand_sep'], 'woocommerce_price_decimal_sep' => $country_info['decimal_sep'], 'woocommerce_price_num_decimals' => $country_info['num_decimals'], 'woocommerce_weight_unit' => $country_info['weight_unit'], 'woocommerce_dimension_unit' => $country_info['dimension_unit'], ); foreach ( $currency_settings as $key => $value ) { update_option( $key, $value ); } return new WP_REST_Response( array(), 204 ); } /** * Prepare objects query. * * @param array $params The params sent in the request. * @return array */ protected function prepare_objects_query( $params ) { $args = array(); $properties = self::get_profile_properties(); foreach ( $properties as $key => $property ) { if ( isset( $params[ $key ] ) ) { $args[ $key ] = $params[ $key ]; } } /** * Filter the query arguments for a request. * * Enables adding extra arguments or setting defaults for a post * collection request. * * @since 6.5.0 * @param array $args Key value array of query var to query value. * @param array $params The params sent in the request. */ $args = apply_filters( 'woocommerce_rest_onboarding_profile_object_query', $args, $params ); return $args; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @since 6.5.0 * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_onboarding_prepare_profile', $response, $item, $request ); } /** * Get onboarding profile properties. * * @return array */ public static function get_profile_properties() { $properties = array( 'completed' => array( 'type' => 'boolean', 'description' => __( 'Whether or not the profile was completed.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'skipped' => array( 'type' => 'boolean', 'description' => __( 'Whether or not the profile was skipped.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'industry' => array( 'type' => 'array', 'description' => __( 'Industry.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ), 'business_extensions' => array( 'type' => 'array', 'description' => __( 'Extra business extensions to install.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ), 'is_agree_marketing' => array( 'type' => 'boolean', 'description' => __( 'Whether or not this store agreed to receiving marketing contents from WooCommerce.com.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'store_email' => array( 'type' => 'string', 'description' => __( 'Store email address.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'validate_callback' => array( __CLASS__, 'rest_validate_marketing_email' ), ), 'is_store_country_set' => array( 'type' => 'boolean', 'description' => __( 'Whether or not this store country is set via onboarding profiler.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'is_plugins_page_skipped' => array( 'type' => 'boolean', 'description' => __( 'Whether or not plugins step in core profiler was skipped.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'business_choice' => array( 'type' => 'string', 'description' => __( 'Business choice.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, ), 'selling_online_answer' => array( 'type' => 'string', 'description' => __( 'Selling online answer.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, ), 'selling_platforms' => array( 'type' => array( 'array', 'null' ), 'description' => __( 'Selling platforms.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'items' => array( 'type' => array( 'string', 'null' ), ), ), ); /** * Filters the Onboarding Profile REST API JSON Schema. * * @since 6.5.0 * @param array $properties List of properties. */ return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties ); } /** * Optionally validates email if user agreed to marketing or if email is not empty. * * @param mixed $value Email value. * @param WP_REST_Request $request Request object. * @param string $param Parameter name. * @return true|WP_Error */ public static function rest_validate_marketing_email( $value, $request, $param ) { $is_agree_marketing = $request->get_param( 'is_agree_marketing' ); if ( ( $is_agree_marketing || ! empty( $value ) ) && ! is_email( $value ) ) { return new \WP_Error( 'rest_invalid_email', __( 'Invalid email address', 'woocommerce' ) ); } return true; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { // Unset properties used for collection params. $properties = self::get_profile_properties(); foreach ( $properties as $key => $property ) { unset( $properties[ $key ]['default'] ); unset( $properties[ $key ]['items'] ); unset( $properties[ $key ]['validate_callback'] ); unset( $properties[ $key ]['sanitize_callback'] ); } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'onboarding_profile', 'type' => 'object', 'properties' => $properties, ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { // Unset properties used for item schema. $params = self::get_profile_properties(); foreach ( $params as $key => $param ) { unset( $params[ $key ]['context'] ); unset( $params[ $key ]['readonly'] ); } $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); /** * Filters the Onboarding Profile REST API collection parameters. * * @since 6.5.0 * @param array $params Collection parameters. */ return apply_filters( 'woocommerce_rest_onboarding_profile_collection_params', $params ); } } MobileAppMagicLink.php 0000777 00000004143 15252174340 0010720 0 ustar 00 <?php /** * REST API Data countries controller. * * Handles requests to the /mobile-app endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager; /** * REST API Data countries controller class. * * @internal * @extends WC_REST_Data_Controller */ class MobileAppMagicLink extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'mobile-app'; /** * Register routes. * * @since 7.0.0 */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/send-magic-link', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'send_magic_link' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); parent::register_routes(); } /** * Sends request to generate magic link email. * * @return \WP_REST_Response|\WP_Error */ public function send_magic_link() { // Attempt to get email from Jetpack. if ( class_exists( Jetpack_Connection_Manager::class ) ) { $jetpack_connection_manager = new Jetpack_Connection_Manager(); if ( $jetpack_connection_manager->is_active() ) { if ( class_exists( 'Jetpack_IXR_Client' ) ) { $xml = new \Jetpack_IXR_Client( array( 'user_id' => get_current_user_id(), ) ); $xml->query( 'jetpack.sendMobileMagicLink', array( 'app' => 'woocommerce' ) ); if ( $xml->isError() ) { return new \WP_Error( 'error_sending_mobile_magic_link', sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() ) ); } return rest_ensure_response( array( 'code' => 'success', ) ); } } } return new \WP_Error( 'jetpack_not_connected', __( 'Jetpack is not connected.', 'woocommerce' ) ); } } Orders.php 0000777 00000024210 15252174340 0006524 0 ustar 00 <?php /** * REST API Orders Controller * * Handles requests to /orders/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use Automattic\WooCommerce\Utilities\OrderUtil; /** * Orders controller. * * @internal * @extends WC_REST_Orders_Controller */ class Orders extends \WC_REST_Orders_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); // This needs to remain a string to support extensions that filter Order Number. $params['number'] = array( 'description' => __( 'Limit result set to orders matching part of an order number.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); // Fix the default 'status' value until it can be patched in core. $params['status']['default'] = array( 'any' ); // Analytics settings may affect the allowed status list. $params['status']['items']['enum'] = ReportsController::get_order_statuses(); return $params; } /** * Prepare objects query. * * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['number'] ) ) { $args = $this->search_partial_order_number( $request['number'], $args ); } return $args; } /** * Helper method to allow searching by partial order number. * * @param int $number Partial order number match. * @param array $args List of arguments for the request. * * @return array Modified args with partial order search included. */ private function search_partial_order_number( $number, $args ) { global $wpdb; $partial_number = trim( $number ); $limit = intval( $args['posts_per_page'] ); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { $order_table_name = OrdersTableDataStore::get_orders_table_name(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table_name is hardcoded. $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM $order_table_name WHERE type = 'shop_order' AND id LIKE %s LIMIT %d", $wpdb->esc_like( absint( $partial_number ) ) . '%', $limit ) ); // phpcs:enable } else { $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->prefix}posts WHERE post_type = 'shop_order' AND ID LIKE %s LIMIT %d", $wpdb->esc_like( absint( $partial_number ) ) . '%', $limit ) ); } // Force WP_Query return empty if don't found any order. $order_ids = empty( $order_ids ) ? array( 0 ) : $order_ids; $args['post__in'] = $order_ids; return $args; } /** * Get product IDs, names, and quantity from order ID. * * @param array $order_id ID of order. * @return array */ protected function get_products_by_order_id( $order_id ) { global $wpdb; $order_items_table = $wpdb->prefix . 'woocommerce_order_items'; $order_itemmeta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $products = $wpdb->get_results( $wpdb->prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, order_itemmeta.meta_value as product_id, order_itemmeta_2.meta_value as product_quantity, order_itemmeta_3.meta_value as variation_id, {$wpdb->posts}.post_title as product_name FROM {$order_items_table} order_items LEFT JOIN {$order_itemmeta_table} order_itemmeta on order_items.order_item_id = order_itemmeta.order_item_id LEFT JOIN {$order_itemmeta_table} order_itemmeta_2 on order_items.order_item_id = order_itemmeta_2.order_item_id LEFT JOIN {$order_itemmeta_table} order_itemmeta_3 on order_items.order_item_id = order_itemmeta_3.order_item_id LEFT JOIN {$wpdb->posts} on {$wpdb->posts}.ID = order_itemmeta.meta_value WHERE order_id = ( %d ) AND order_itemmeta.meta_key = '_product_id' AND order_itemmeta_2.meta_key = '_qty' AND order_itemmeta_3.meta_key = '_variation_id' GROUP BY product_id ", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $order_id ), ARRAY_A ); return $products; } /** * Get customer data from customer_id. * * @param array $customer_id ID of customer. * @return array */ protected function get_customer_by_id( $customer_id ) { global $wpdb; $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$customer_lookup_table} WHERE customer_id = ( %d )", $customer_id ), ARRAY_A ); return $customer; } /** * Get formatted item data. * * @param WC_Data $object WC_Data instance. * @return array */ protected function get_formatted_item_data( $object ) { $extra_fields = array( 'customer', 'products' ); $fields = false; // Determine if the response fields were specified. if ( ! empty( $this->request['_fields'] ) ) { $fields = wp_parse_list( $this->request['_fields'] ); if ( 0 === count( $fields ) ) { $fields = false; } else { $fields = array_map( 'trim', $fields ); } } // Initially skip line items if we can. $using_order_class_override = is_a( $object, '\Automattic\WooCommerce\Admin\Overrides\Order' ); if ( $using_order_class_override ) { $data = $object->get_data_without_line_items(); } else { $data = $object->get_data(); } $extra_fields = false === $fields ? array() : array_intersect( $extra_fields, $fields ); $format_decimal = array( 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax' ); $format_date = array( 'date_created', 'date_modified', 'date_completed', 'date_paid' ); $format_line_items = array( 'line_items', 'tax_lines', 'shipping_lines', 'fee_lines', 'coupon_lines' ); // Add extra data as necessary. $extra_data = array(); foreach ( $extra_fields as $field ) { switch ( $field ) { case 'customer': $extra_data['customer'] = $this->get_customer_by_id( $data['customer_id'] ); break; case 'products': $extra_data['products'] = $this->get_products_by_order_id( $object->get_id() ); break; } } // Format decimal values. foreach ( $format_decimal as $key ) { $data[ $key ] = wc_format_decimal( $data[ $key ], $this->request['dp'] ); } // format total with order currency. if ( $object instanceof \WC_Order ) { $data['total_formatted'] = wp_strip_all_tags( html_entity_decode( $object->get_formatted_order_total() ), true ); } // Format date values. foreach ( $format_date as $key ) { $datetime = $data[ $key ]; $data[ $key ] = wc_rest_prepare_date_response( $datetime, false ); $data[ $key . '_gmt' ] = wc_rest_prepare_date_response( $datetime ); } // Format the order status. $data['status'] = OrderUtil::remove_status_prefix( $data['status'] ); // Format requested line items. $formatted_line_items = array(); foreach ( $format_line_items as $key ) { if ( false === $fields || in_array( $key, $fields, true ) ) { if ( $using_order_class_override ) { $line_item_data = $object->get_line_item_data( $key ); } else { $line_item_data = $data[ $key ]; } $formatted_line_items[ $key ] = array_values( array_map( array( $this, 'get_order_item_data' ), $line_item_data ) ); } } // Refunds. $data['refunds'] = array(); foreach ( $object->get_refunds() as $refund ) { $data['refunds'][] = array( 'id' => $refund->get_id(), 'reason' => $refund->get_reason() ? $refund->get_reason() : '', 'total' => '-' . wc_format_decimal( $refund->get_amount(), $this->request['dp'] ), ); } return array_merge( array( 'id' => $object->get_id(), 'parent_id' => $data['parent_id'], 'number' => $data['number'], 'order_key' => $data['order_key'], 'created_via' => $data['created_via'], 'version' => $data['version'], 'status' => $data['status'], 'currency' => $data['currency'], 'date_created' => $data['date_created'], 'date_created_gmt' => $data['date_created_gmt'], 'date_modified' => $data['date_modified'], 'date_modified_gmt' => $data['date_modified_gmt'], 'discount_total' => $data['discount_total'], 'discount_tax' => $data['discount_tax'], 'shipping_total' => $data['shipping_total'], 'shipping_tax' => $data['shipping_tax'], 'cart_tax' => $data['cart_tax'], 'total' => $data['total'], 'total_formatted' => isset( $data['total_formatted'] ) ? $data['total_formatted'] : $data['total'], 'total_tax' => $data['total_tax'], 'prices_include_tax' => $data['prices_include_tax'], 'customer_id' => $data['customer_id'], 'customer_ip_address' => $data['customer_ip_address'], 'customer_user_agent' => $data['customer_user_agent'], 'customer_note' => $data['customer_note'], 'billing' => $data['billing'], 'shipping' => $data['shipping'], 'payment_method' => $data['payment_method'], 'payment_method_title' => $data['payment_method_title'], 'transaction_id' => $data['transaction_id'], 'date_paid' => $data['date_paid'], 'date_paid_gmt' => $data['date_paid_gmt'], 'date_completed' => $data['date_completed'], 'date_completed_gmt' => $data['date_completed_gmt'], 'cart_hash' => $data['cart_hash'], 'meta_data' => $data['meta_data'], 'refunds' => $data['refunds'], ), $formatted_line_items, $extra_data ); } } PaymentGatewaySuggestions.php 0000777 00000013556 15252174340 0012473 0 ustar 00 <?php /** * REST API Payment Gateway Suggestions Controller * * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions; defined( 'ABSPATH' ) || exit; /** * PaymentGatewaySuggetsions Controller. * * @internal * @extends WC_REST_Data_Controller */ class PaymentGatewaySuggestions extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'payment-gateway-suggestions'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_suggestions' ), 'permission_callback' => array( $this, 'user_can_manage_woocommerce' ), 'args' => array( 'force_default_suggestions' => array( 'type' => 'boolean', 'description' => __( 'Return the default payment suggestions when woocommerce_show_marketplace_suggestions and woocommerce_setting_payments_recommendations_hidden options are set to no', 'woocommerce' ), ), ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/dismiss', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'dismiss_payment_gateway_suggestion' ), 'permission_callback' => array( $this, 'get_permission_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_permission_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @return \WP_Error|boolean */ public function user_can_manage_woocommerce() { if ( current_user_can( 'manage_woocommerce' ) ) { return true; } return new \WP_Error( 'woocommerce_rest_invalid_user', __( 'You are not allowed to make this request.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Return suggested payment gateways. * * @param WP_REST_Request $request Full details about the request. * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_suggestions( $request ) { $should_display = Suggestions::should_display(); $force_default = $request->get_param( 'force_default_suggestions' ); if ( $should_display ) { return Suggestions::get_suggestions(); } elseif ( false === $should_display && true === $force_default ) { return rest_ensure_response( Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ) ); } return rest_ensure_response( array() ); } /** * Dismisses suggested payment gateways. * * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function dismiss_payment_gateway_suggestion() { $success = Suggestions::dismiss(); return rest_ensure_response( $success ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'payment-gateway-suggestions', 'type' => 'object', 'properties' => array( 'content' => array( 'description' => __( 'Suggestion description.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'id' => array( 'description' => __( 'Suggestion ID.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'image' => array( 'description' => __( 'Gateway image.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_visible' => array( 'description' => __( 'Suggestion visibility.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'plugins' => array( 'description' => __( 'Array of plugin slugs.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'recommendation_priority' => array( 'description' => __( 'Priority of recommendation.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Gateway title.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'transaction_processors' => array( 'description' => __( 'Array of transaction processors and their images.', 'woocommerce' ), 'type' => 'object', 'addtionalProperties' => array( 'type' => 'string', 'format' => 'uri', ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } Taxes.php 0000777 00000011634 15252174340 0006360 0 ustar 00 <?php /** * REST API Taxes Controller * * Handles requests to /taxes/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Taxes controller. * * @internal * @extends WC_REST_Taxes_Controller */ class Taxes extends \WC_REST_Taxes_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar tax code.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['include'] = array( 'description' => __( 'Limit result set to items that have the specified rate ID(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get all taxes and allow filtering by tax code. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { global $wpdb; $prepared_args = array(); $prepared_args['order'] = $request['order']; $prepared_args['number'] = $request['per_page']; if ( ! empty( $request['offset'] ) ) { $prepared_args['offset'] = $request['offset']; } else { $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; } $orderby_possibles = array( 'id' => 'tax_rate_id', 'order' => 'tax_rate_order', ); $prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ]; $prepared_args['class'] = $request['class']; $prepared_args['search'] = $request['search']; $prepared_args['include'] = $request['include']; /** * Filter arguments, before passing to $wpdb->get_results(), when querying taxes via the REST API. * * @param array $prepared_args Array of arguments for $wpdb->get_results(). * @param WP_REST_Request $request The current request. */ $prepared_args = apply_filters( 'woocommerce_rest_tax_query', $prepared_args, $request ); $query = " SELECT * FROM {$wpdb->prefix}woocommerce_tax_rates WHERE 1 = 1 "; // Filter by tax class. if ( ! empty( $prepared_args['class'] ) ) { $class = 'standard' !== $prepared_args['class'] ? sanitize_title( $prepared_args['class'] ) : ''; $query .= " AND tax_rate_class = '$class'"; } // Filter by tax code. $tax_code_search = $prepared_args['search']; if ( $tax_code_search ) { $code_like = '%' . $wpdb->esc_like( $tax_code_search ) . '%'; $query .= $wpdb->prepare( ' AND CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") ) LIKE %s', $code_like ); } // Filter by included tax rate IDs. $included_taxes = array_map( 'absint', $prepared_args['include'] ); if ( ! empty( $included_taxes ) ) { $included_taxes = implode( ',', $prepared_args['include'] ); $query .= " AND tax_rate_id IN ({$included_taxes})"; } // Order tax rates. $order_by = sprintf( ' ORDER BY %s', sanitize_key( $prepared_args['orderby'] ) ); // Pagination. $pagination = sprintf( ' LIMIT %d, %d', $prepared_args['offset'], $prepared_args['number'] ); // Query taxes. $results = $wpdb->get_results( $query . $order_by . $pagination ); // @codingStandardsIgnoreLine. $taxes = array(); foreach ( $results as $tax ) { $data = $this->prepare_item_for_response( $tax, $request ); $taxes[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $taxes ); // Store pagination values for headers then unset for count query. $per_page = (int) $prepared_args['number']; $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 ); // Query only for ids. $wpdb->get_results( str_replace( 'SELECT *', 'SELECT tax_rate_id', $query ) ); // @codingStandardsIgnoreLine. // Calculate totals. $total_taxes = (int) $wpdb->num_rows; $response->header( 'X-WP-Total', (int) $total_taxes ); $max_pages = ceil( $total_taxes / $per_page ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } } DataDownloadIPs.php 0000777 00000010230 15252174340 0010240 0 ustar 00 <?php /** * REST API Data Download IP Controller * * Handles requests to /data/download-ips */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data Download IP controller. * * @internal * @extends WC_REST_Data_Controller */ class DataDownloadIPs extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'data/download-ips'; /** * Register routes. * * @since 3.5.0 */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Return the download IPs matching the passed parameters. * * @since 3.5.0 * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { global $wpdb; if ( isset( $request['match'] ) ) { $downloads = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT( user_ip_address ) FROM {$wpdb->prefix}wc_download_log WHERE user_ip_address LIKE %s LIMIT 10", $request['match'] . '%' ) ); } else { return new \WP_Error( 'woocommerce_rest_data_download_ips_invalid_request', __( 'Invalid request. Please pass the match parameter.', 'woocommerce' ), array( 'status' => 400 ) ); } $data = array(); if ( ! empty( $downloads ) ) { foreach ( $downloads as $download ) { $response = $this->prepare_item_for_response( $download, $request ); $data[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $data ); } /** * Prepare the data object for response. * * @since 3.5.0 * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); $response->add_links( $this->prepare_links( $item ) ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_data_download_ip', $response, $item, $request ); } /** * Prepare links for the request. * * @param object $item Data object. * @return array Links for the given object. */ protected function prepare_links( $item ) { $links = array( 'collection' => array( 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), ), ); return $links; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['match'] = array( 'description' => __( 'A partial IP address can be passed and matching results will be returned.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'data_download_ips', 'type' => 'object', 'properties' => array( 'user_ip_address' => array( 'type' => 'string', 'description' => __( 'IP address.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } LaunchYourStore.php 0000777 00000012231 15252174340 0010374 0 ustar 00 <?php /** * REST API Launch Your Store Controller * * Handles requests to /launch-your-store/* */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\WCAdminHelper; defined( 'ABSPATH' ) || exit; /** * Launch Your Store controller. * * @internal */ class LaunchYourStore { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'launch-your-store'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/initialize-coming-soon', array( array( 'methods' => 'POST', 'callback' => array( $this, 'initialize_coming_soon' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update-survey-status', array( array( 'methods' => 'POST', 'callback' => array( $this, 'update_survey_status' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), 'args' => array( 'status' => array( 'type' => 'string', 'enum' => array( 'yes', 'no' ), ), ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/survey-completed', array( array( 'methods' => 'GET', 'callback' => array( $this, 'has_survey_completed' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/woopayments/test-orders/count', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_woopay_test_orders_count' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/woopayments/test-orders', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_woopay_test_orders' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); } /** * User must be either shop_manager or administrator. * * @return bool */ public function must_be_shop_manager_or_admin() { // phpcs:ignore if ( ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'administrator' ) ) { return false; } return true; } /** * Initializes options for coming soon. Overwrites existing coming soon status but keeps the private link and share key. * * @return bool|void */ public function initialize_coming_soon() { $current_user_id = get_current_user_id(); // Abort if we don't have a user id for some reason. if ( ! $current_user_id ) { return; } $coming_soon = 'yes'; $store_pages_only = WCAdminHelper::is_site_fresh() ? 'no' : 'yes'; $private_link = 'no'; $share_key = wp_generate_password( 32, false ); update_option( 'woocommerce_coming_soon', $coming_soon ); update_option( 'woocommerce_store_pages_only', $store_pages_only ); add_option( 'woocommerce_private_link', $private_link ); add_option( 'woocommerce_share_key', $share_key ); wc_admin_record_tracks_event( 'launch_your_store_initialize_coming_soon', array( 'coming_soon' => $coming_soon, 'store_pages_only' => $store_pages_only, 'private_link' => $private_link, ) ); return true; } /** * Count the test orders created during Woo Payments test mode. * * @return \WP_REST_Response */ public function get_woopay_test_orders_count() { $return = function ( $count ) { return new \WP_REST_Response( array( 'count' => $count ) ); }; $orders = wc_get_orders( array( // phpcs:ignore 'meta_key' => '_wcpay_mode', // phpcs:ignore 'meta_value' => 'test', 'return' => 'ids', ) ); return $return( count( $orders ) ); } /** * Delete WooPayments test orders. * * @return \WP_REST_Response */ public function delete_woopay_test_orders() { $return = function ( $status = 204 ) { return new \WP_REST_Response( null, $status ); }; $orders = wc_get_orders( array( // phpcs:ignore 'meta_key' => '_wcpay_mode', // phpcs:ignore 'meta_value' => 'test', ) ); foreach ( $orders as $order ) { $order->delete(); } return $return(); } /** * Update woocommerce_admin_launch_your_store_survey_completed to yes or no * * @param \WP_REST_Request $request WP_REST_Request object. * * @return \WP_REST_Response */ public function update_survey_status( \WP_REST_Request $request ) { update_option( 'woocommerce_admin_launch_your_store_survey_completed', $request->get_param( 'status' ) ); return new \WP_REST_Response(); } /** * Return woocommerce_admin_launch_your_store_survey_completed option. * * @return \WP_REST_Response */ public function has_survey_completed() { return new \WP_REST_Response( get_option( 'woocommerce_admin_launch_your_store_survey_completed', 'no' ) ); } } OnboardingThemes.php 0000777 00000012764 15252174340 0010531 0 ustar 00 <?php /** * REST API Onboarding Themes Controller * * Handles requests to install and activate themes. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Onboarding Themes Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingThemes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/themes'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'install_theme' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_theme' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to manage themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'switch_themes' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage themes.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Installs the requested theme. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|array Theme installation status. */ public function install_theme( $request ) { $theme = sanitize_text_field( $request['theme'] ); $installed_themes = wp_get_themes(); if ( in_array( $theme, array_keys( $installed_themes ), true ) ) { return( array( 'slug' => $theme, 'name' => $installed_themes[ $theme ]->get( 'Name' ), 'status' => 'success', ) ); } include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/theme-install.php'; include_once ABSPATH . '/wp-admin/includes/theme.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php'; $api = themes_api( 'theme_information', array( 'slug' => $theme, 'fields' => array( 'sections' => false, ), ) ); if ( is_wp_error( $api ) ) { return new \WP_Error( 'woocommerce_rest_theme_install', sprintf( /* translators: %s: theme slug (example: woocommerce-services) */ __( 'The requested theme `%s` could not be installed. Theme API call failed.', 'woocommerce' ), $theme ), 500 ); } $upgrader = new \Theme_Upgrader( new \Automatic_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); if ( is_wp_error( $result ) || is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_theme_install', sprintf( /* translators: %s: theme slug (example: woocommerce-services) */ __( 'The requested theme `%s` could not be installed.', 'woocommerce' ), $theme ), 500 ); } return array( 'slug' => $theme, 'name' => $api->name, 'status' => 'success', ); } /** * Activate the requested theme. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|array Theme activation status. */ public function activate_theme( $request ) { $theme = sanitize_text_field( $request['theme'] ); require_once ABSPATH . 'wp-admin/includes/theme.php'; $installed_themes = wp_get_themes(); if ( ! in_array( $theme, array_keys( $installed_themes ), true ) ) { /* translators: %s: theme slug (example: woocommerce-services) */ return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'Invalid theme %s.', 'woocommerce' ), $theme ), 404 ); } $result = switch_theme( $theme ); if ( ! is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'The requested theme could not be activated.', 'woocommerce' ), $theme ), 500 ); } return( array( 'slug' => $theme, 'name' => $installed_themes[ $theme ]->get( 'Name' ), 'status' => 'success', ) ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'onboarding_theme', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'Theme slug.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Theme name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Theme status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } OnboardingPlugins.php 0000777 00000025250 15252174340 0010717 0 ustar 00 <?php /** * REST API Onboarding Profile Controller * * Handles requests to /onboarding/profile */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection; use WC_REST_Data_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; /** * Onboarding Plugins controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingPlugins extends WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/plugins'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install-and-activate-async', array( array( 'methods' => 'POST', 'callback' => array( $this, 'install_and_activate_async' ), 'permission_callback' => array( $this, 'can_install_and_activate_plugins' ), 'args' => array( 'plugins' => array( 'description' => 'A list of plugins to install', 'type' => 'array', 'items' => 'string', 'sanitize_callback' => function ( $value ) { return array_map( function ( $value ) { return sanitize_text_field( $value ); }, $value ); }, 'required' => true, ), 'source' => array( 'description' => 'The source of the request', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => false, ), ), ), 'schema' => array( $this, 'get_install_async_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install-and-activate', array( array( 'methods' => 'POST', 'callback' => array( $this, 'install_and_activate' ), 'permission_callback' => array( $this, 'can_install_and_activate_plugins' ), ), 'schema' => array( $this, 'get_install_activate_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/scheduled-installs/(?P<job_id>\w+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_scheduled_installs' ), 'permission_callback' => array( $this, 'can_install_plugins' ), ), 'schema' => array( $this, 'get_install_async_schema' ), ) ); // This is an experimental endpoint and is subject to change in the future. register_rest_route( $this->namespace, '/' . $this->rest_base . '/jetpack-authorization-url', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_jetpack_authorization_url' ), 'permission_callback' => array( $this, 'can_install_plugins' ), 'args' => array( 'redirect_url' => array( 'description' => 'The URL to redirect to after authorization', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true, ), 'from' => array( 'description' => 'from value for the jetpack authorization page', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => false, 'default' => 'woocommerce-onboarding', ), ), ), ) ); add_action( 'woocommerce_plugins_install_error', array( $this, 'log_plugins_install_error' ), 10, 4 ); add_action( 'woocommerce_plugins_install_api_error', array( $this, 'log_plugins_install_api_error' ), 10, 2 ); } /** * Install and activate a plugin. * * @param WP_REST_Request $request WP Request object. * * @return WP_REST_Response */ public function install_and_activate( WP_REST_Request $request ) { $response = array(); $response['install'] = PluginsHelper::install_plugins( $request->get_param( 'plugins' ) ); $response['activate'] = PluginsHelper::activate_plugins( $response['install']['installed'] ); return new WP_REST_Response( $response ); } /** * Queue plugin install request. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array */ public function install_and_activate_async( WP_REST_Request $request ) { $plugins = $request->get_param( 'plugins' ); $source = $request->get_param( 'source' ); $job_id = uniqid(); WC()->queue()->add( 'woocommerce_plugins_install_and_activate_async_callback', array( $plugins, $job_id, $source ) ); $plugin_status = array(); foreach ( $plugins as $plugin ) { $plugin_status[ $plugin ] = array( 'status' => 'pending', 'errors' => array(), ); } return array( 'job_id' => $job_id, 'status' => 'pending', 'plugins' => $plugin_status, ); } /** * Returns current status of given job. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array|WP_REST_Response */ public function get_scheduled_installs( WP_REST_Request $request ) { $job_id = $request->get_param( 'job_id' ); $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_install_and_activate_async_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); $actions = array_filter( PluginsHelper::get_action_data( $actions ), function ( $action ) use ( $job_id ) { return $action['job_id'] === $job_id; } ); if ( empty( $actions ) ) { return new WP_REST_Response( null, 404 ); } $response = array( 'job_id' => $actions[0]['job_id'], 'status' => $actions[0]['status'], ); $option = get_option( 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id ); if ( isset( $option['plugins'] ) ) { $response['plugins'] = $option['plugins']; } return $response; } /** * Return Jetpack authorization URL. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array */ public function get_jetpack_authorization_url( WP_REST_Request $request ) { return JetpackConnection::get_authorization_url( $request->get_param( 'redirect_url' ), $request->get_param( 'from' ) ); } /** * Check whether the current user has permission to install plugins * * @return WP_Error|boolean */ public function can_install_plugins() { if ( ! current_user_can( 'install_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether the current user has permission to install and activate plugins * * @return WP_Error|boolean */ public function can_install_and_activate_plugins() { if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * JSON Schema for both install-async and scheduled-installs endpoints. * * @return array */ public function get_install_async_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Install Async Schema', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'job_id' => 'integer', 'status' => array( 'type' => 'string', 'enum' => array( 'pending', 'complete', 'failed' ), ), ), ), ); } /** * JSON Schema for install-and-activate endpoint. * * @return array */ public function get_install_activate_schema() { $error_schema = array( 'type' => 'object', 'patternProperties' => array( '^.*$' => array( 'type' => 'string', ), ), 'items' => array( 'type' => 'string', ), ); $install_schema = array( 'type' => 'object', 'properties' => array( 'installed' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'results' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'errors' => array( 'type' => 'object', 'properties' => array( 'errors' => $error_schema, 'error_data' => $error_schema, ), ), ), ); $activate_schema = array( 'type' => 'object', 'properties' => array( 'activated' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'active' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'errors' => array( 'type' => 'object', 'properties' => array( 'errors' => $error_schema, 'error_data' => $error_schema, ), ), ), ); return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Install and Activate Schema', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'install' => $install_schema, 'activate' => $activate_schema, ), ), ); } public function log_plugins_install_error( $slug, $api, $result, $upgrader ) { $properties = array( 'error_message' => sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed.', 'woocommerce' ), $slug ), 'type' => 'plugin_info_api_error', 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, 'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ), 'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null', ); wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties ); } public function log_plugins_install_api_error( $slug, $api ) { $properties = array( 'error_message' => sprintf( // translators: %s: plugin slug (example: woocommerce-services). __( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ), $slug ), 'type' => 'plugin_install_error', 'api_error_message' => $api->get_error_message(), 'slug' => $slug, ); wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties ); } } Notes.php 0000777 00000063447 15252174340 0006375 0 ustar 00 <?php /** * REST API Admin Notes controller * * Handles requests to the admin notes endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\Notes\Notes as NotesRepository; /** * REST API Admin Notes controller class. * * @internal * @extends WC_REST_CRUD_Controller */ class Notes extends \WC_REST_CRUD_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'admin/notes'; /** * Register the routes for admin notes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d-]+)', array( 'args' => array( 'id' => array( 'description' => __( 'Unique ID for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete/(?P<id>[\d-]+)', array( array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete/all', array( array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_all_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'status' => array( 'description' => __( 'Status of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_statuses(), 'type' => 'string', ), ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/tracker/(?P<note_id>[\d-]+)/user/(?P<user_id>[\d-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'track_opened_email' ), 'permission_callback' => '__return_true', ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'batch_update_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/experimental-activate-promo/(?P<promo_note_name>[\w-]+)', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_promo_note' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get a single note. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } if ( is_wp_error( $note ) ) { return $note; } $data = $this->prepare_note_data_for_response( $note, $request ); return rest_ensure_response( $data ); } /** * Get all notes. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response */ public function get_items( $request ) { $query_args = $this->prepare_objects_query( $request ); $notes = NotesRepository::get_notes( 'edit', $query_args ); $data = array(); foreach ( (array) $notes as $note_obj ) { $note = $this->prepare_item_for_response( $note_obj, $request ); $note = $this->prepare_response_for_collection( $note ); $data[] = $note; } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', count( $data ) ); return $response; } /** * Checks if user is in tasklist experiment. * * @return bool Whether remote inbox notifications are enabled. */ private function is_tasklist_experiment_assigned_treatment() { $anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : ''; $allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' ); $abtest = new \WooCommerce\Admin\Experimental_Abtest( $anon_id, 'woocommerce', $allow_tracking ); $date = new \DateTime(); $date->setTimeZone( new \DateTimeZone( 'UTC' ) ); $experiment_name = sprintf( 'woocommerce_tasklist_progression_headercard_%s_%s', $date->format( 'Y' ), $date->format( 'm' ) ); $experiment_name_2col = sprintf( 'woocommerce_tasklist_progression_headercard_2col_%s_%s', $date->format( 'Y' ), $date->format( 'm' ) ); return $abtest->get_variation( $experiment_name ) === 'treatment' || $abtest->get_variation( $experiment_name_2col ) === 'treatment'; } /** * Prepare objects query. * * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_objects_query( $request ) { $args = array(); $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['per_page'] = $request['per_page']; $args['page'] = $request['page']; $args['type'] = isset( $request['type'] ) ? $request['type'] : array(); $args['status'] = isset( $request['status'] ) ? $request['status'] : array(); $args['source'] = isset( $request['source'] ) ? $request['source'] : array(); $args['is_deleted'] = 0; if ( isset( $request['is_read'] ) ) { $args['is_read'] = filter_var( $request['is_read'], FILTER_VALIDATE_BOOLEAN ); } if ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date_created'; } /** * Filter the query arguments for a request. * * Enables adding extra arguments or setting defaults for a post * collection request. * * @param array $args Key value array of query var to query value. * @param WP_REST_Request $request The request used. * @since 3.9.0 */ $args = apply_filters( 'woocommerce_rest_notes_object_query', $args, $request ); return $args; } /** * Check whether a given request has permission to read a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_item_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether a given request has permission to read notes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Update a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function update_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } NotesRepository::update_note( $note, $this->get_requested_updates( $request ) ); return $this->get_item( $request ); } /** * Delete a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function delete_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no note with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } NotesRepository::delete_note( $note ); $data = $this->prepare_note_data_for_response( $note, $request ); return rest_ensure_response( $data ); } /** * Delete all notes. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function delete_all_items( $request ) { $args = array(); if ( isset( $request['status'] ) ) { $args['status'] = $request['status']; } $notes = NotesRepository::delete_all_notes( $args ); $data = array(); foreach ( (array) $notes as $note_obj ) { $data[] = $this->prepare_note_data_for_response( $note_obj, $request ); } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) ); return $response; } /** * Prepare note data. * * @param Note $note Note data. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response $response Response data. */ public function prepare_note_data_for_response( $note, $request ) { $note = $note->get_data(); $note = $this->prepare_item_for_response( $note, $request ); return $this->prepare_response_for_collection( $note ); } /** * Prepare an array with the requested updates. * * @param WP_REST_Request $request Request object. * @return array A list of the requested updates values. */ protected function get_requested_updates( $request ) { $requested_updates = array(); if ( ! is_null( $request->get_param( 'status' ) ) ) { $requested_updates['status'] = $request->get_param( 'status' ); } if ( ! is_null( $request->get_param( 'date_reminder' ) ) ) { $requested_updates['date_reminder'] = $request->get_param( 'date_reminder' ); } if ( ! is_null( $request->get_param( 'is_deleted' ) ) ) { $requested_updates['is_deleted'] = $request->get_param( 'is_deleted' ); } if ( ! is_null( $request->get_param( 'is_read' ) ) ) { $requested_updates['is_read'] = $request->get_param( 'is_read' ); } return $requested_updates; } /** * Batch update a set of notes. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function batch_update_items( $request ) { $data = array(); $note_ids = $request->get_param( 'noteIds' ); if ( ! isset( $note_ids ) || ! is_array( $note_ids ) ) { return new \WP_Error( 'woocommerce_note_invalid_ids', __( 'Please provide an array of IDs through the noteIds param.', 'woocommerce' ), array( 'status' => 422 ) ); } foreach ( (array) $note_ids as $note_id ) { $note = NotesRepository::get_note( (int) $note_id ); if ( $note ) { NotesRepository::update_note( $note, $this->get_requested_updates( $request ) ); $data[] = $this->prepare_note_data_for_response( $note, $request ); } } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) ); return $response; } /** * Activate a promo note, create if not exist. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function activate_promo_note( $request ) { /** * Filter allowed promo notes for experimental-activate-promo. * * @param array $promo_notes Array of allowed promo notes. * @since 7.8.0 */ $allowed_promo_notes = apply_filters( 'woocommerce_admin_allowed_promo_notes', [] ); $promo_note_name = $request->get_param( 'promo_note_name' ); if ( ! in_array( $promo_note_name, $allowed_promo_notes, true ) ) { return new \WP_Error( 'woocommerce_note_invalid_promo_note_name', __( 'Please provide a valid promo note name.', 'woocommerce' ), array( 'status' => 422 ) ); } $data_store = NotesRepository::load_data_store(); $note_ids = $data_store->get_notes_with_name( $promo_note_name ); if ( empty( $note_ids ) ) { // Promo note doesn't exist, this could happen in cases where // user might have disabled RemoteInboxNotications via disabling // marketing suggestions. Thus we'd have to manually add the note. $note = new Note(); $note->set_name( $promo_note_name ); $note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED ); $data_store->create( $note ); } else { $note = NotesRepository::get_note( $note_ids[0] ); NotesRepository::update_note( $note, [ 'status' => Note::E_WC_ADMIN_NOTE_ACTIONED, ] ); } return rest_ensure_response( array( 'success' => true, ) ); } /** * Makes sure the current user has access to WRITE the settings APIs. * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|bool */ public function update_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Prepare a path or query for serialization to the client. * * @param string $query The query, path, or URL to transform. * @return string A fully formed URL. */ public function prepare_query_for_response( $query ) { if ( empty( $query ) ) { return $query; } if ( 'https://' === substr( $query, 0, 8 ) ) { return $query; } if ( 'http://' === substr( $query, 0, 7 ) ) { return $query; } if ( '?' === substr( $query, 0, 1 ) ) { return admin_url( 'admin.php' . $query ); } return admin_url( $query ); } /** * Maybe add a nonce to a URL. * * @link https://codex.wordpress.org/WordPress_Nonces * * @param string $url The URL needing a nonce. * @param string $action The nonce action. * @param string $name The nonce name. * @return string A fully formed URL. */ private function maybe_add_nonce_to_url( string $url, string $action = '', string $name = '' ) : string { if ( empty( $action ) ) { return $url; } if ( empty( $name ) ) { // Default parameter name. $name = '_wpnonce'; } return add_query_arg( $name, wp_create_nonce( $action ), $url ); } /** * Prepare a note object for serialization. * * @param array $data Note data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $data, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data['date_created_gmt'] = wc_rest_prepare_date_response( $data['date_created'] ); $data['date_created'] = wc_rest_prepare_date_response( $data['date_created'], false ); $data['date_reminder_gmt'] = wc_rest_prepare_date_response( $data['date_reminder'] ); $data['date_reminder'] = wc_rest_prepare_date_response( $data['date_reminder'], false ); $data['title'] = stripslashes( $data['title'] ); $data['content'] = stripslashes( $data['content'] ); $data['is_snoozable'] = (bool) $data['is_snoozable']; $data['is_deleted'] = (bool) $data['is_deleted']; $data['is_read'] = (bool) $data['is_read']; foreach ( (array) $data['actions'] as $key => $value ) { $data['actions'][ $key ]->label = stripslashes( $data['actions'][ $key ]->label ); $data['actions'][ $key ]->url = $this->maybe_add_nonce_to_url( $this->prepare_query_for_response( $data['actions'][ $key ]->query ), (string) $data['actions'][ $key ]->nonce_action, (string) $data['actions'][ $key ]->nonce_name ); $data['actions'][ $key ]->status = stripslashes( $data['actions'][ $key ]->status ); } $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); $response->add_links( array( 'self' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $data['id'] ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ) ); /** * Filter a note returned from the API. * * Allows modification of the note data right before it is returned. * * @param WP_REST_Response $response The response object. * @param array $data The original note. * @param WP_REST_Request $request Request used to generate the response. * @since 3.9.0 */ return apply_filters( 'woocommerce_rest_prepare_note', $response, $data, $request ); } /** * Track opened emails. * * @param WP_REST_Request $request Request object. */ public function track_opened_email( $request ) { $note = NotesRepository::get_note( $request->get_param( 'note_id' ) ); if ( ! $note ) { return; } NotesRepository::record_tracks_event_with_user( $request->get_param( 'user_id' ), 'email_note_opened', array( 'note_name' => $note->get_name() ) ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'note_id', 'date', 'type', 'title', 'status', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['type'] = array( 'description' => __( 'Type of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_types(), 'type' => 'string', ), ); $params['status'] = array( 'description' => __( 'Status of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_statuses(), 'type' => 'string', ), ); $params['source'] = array( 'description' => __( 'Source of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the note's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'note', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'ID of the note record.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Name of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'type' => array( 'description' => __( 'The type of the note (e.g. error, warning, etc.).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'locale' => array( 'description' => __( 'Locale used for the note title and content.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Title of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'content' => array( 'description' => __( 'Content of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'content_data' => array( 'description' => __( 'Content data for the note. JSON string. Available for re-localization.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'The status of the note (e.g. unactioned, actioned).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), ), 'source' => array( 'description' => __( 'Source of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created' => array( 'description' => __( 'Date the note was created.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created_gmt' => array( 'description' => __( 'Date the note was created (GMT).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_reminder' => array( 'description' => __( 'Date after which the user should be reminded of the note, if any.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, // @todo Allow date_reminder to be updated. ), 'date_reminder_gmt' => array( 'description' => __( 'Date after which the user should be reminded of the note, if any (GMT).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_snoozable' => array( 'description' => __( 'Whether or not a user can request to be reminded about the note.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'actions' => array( 'description' => __( 'An array of actions, if any, for the note.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'layout' => array( 'description' => __( 'The layout of the note (e.g. banner, thumbnail, plain).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'image' => array( 'description' => __( 'The image of the note, if any.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_deleted' => array( 'description' => __( 'Registers whether the note is deleted or not', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_read' => array( 'description' => __( 'Registers whether the note is read or not', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } ProductReviews.php 0000777 00000002462 15252174340 0010260 0 ustar 00 <?php /** * REST API Product Reviews Controller * * Handles requests to /products/reviews. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product reviews controller. * * @internal * @extends WC_REST_Product_Reviews_Controller */ class ProductReviews extends \WC_REST_Product_Reviews_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Prepare links for the request. * * @param WP_Comment $review Product review object. * @return array Links for the given product review. */ protected function prepare_links( $review ) { $links = array( 'self' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $review->comment_ID ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), ), ); if ( 0 !== (int) $review->comment_post_ID ) { $links['up'] = array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $review->comment_post_ID ) ), 'embeddable' => true, ); } if ( 0 !== (int) $review->user_id ) { $links['reviewer'] = array( 'href' => rest_url( 'wp/v2/users/' . $review->user_id ), 'embeddable' => true, ); } return $links; } } OnboardingProductTypes.php 0000777 00000003460 15252174340 0011742 0 ustar 00 <?php /** * REST API Onboarding Product Types Controller * * Handles requests to /onboarding/product-types */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts; defined( 'ABSPATH' ) || exit; /** * Onboarding Product Types Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProductTypes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/product-types'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_product_types' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available product types. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_product_types( $request ) { return OnboardingProducts::get_product_types_with_data(); } } Settings.php 0000777 00000010313 15252174340 0007065 0 ustar 00 <?php /** * REST API Settings Controller * * Handles requests to save Settings. */ declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API; use WC_Admin_Settings; use Automattic\WooCommerce\Admin\Features\Settings\Init; defined( 'ABSPATH' ) || exit; /** * Settings Controller. * * @extends WC_REST_Data_Controller */ class Settings extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'legacy-settings'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'save_items_permissions_check' ), 'args' => array( 'schema' => array( $this, 'save_items_schema' ), ), ), ) ); } /** * Check if a given request has access to update settings. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function save_items_permissions_check( $request ) { return current_user_can( 'manage_woocommerce' ); } /** * Save settings. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function save_settings( $request ) { global $current_section, $current_tab; // Verify nonce. if ( ! check_ajax_referer( 'wp_rest', false, false ) ) { return new \WP_Error( 'woocommerce_settings_invalid_nonce', __( 'Invalid nonce.', 'woocommerce' ), array( 'status' => 403 ) ); } $params = $request->get_params(); try { // Get current tab/section and set global variables. $current_tab = empty( $params['tab'] ) ? 'general' : sanitize_title( wp_unslash( $params['tab'] ) ); // WPCS: input var okay, CSRF ok. $current_section = empty( $params['section'] ) ? '' : sanitize_title( wp_unslash( $params['section'] ) ); // WPCS: input var okay, CSRF ok. $filter_name = '' === $current_section ? "woocommerce_save_settings_{$current_tab}" : "woocommerce_save_settings_{$current_tab}_{$current_section}"; /** * Filters whether to save settings. * * @since 3.7.0 * * @param bool $save Whether to save settings. */ if ( apply_filters( $filter_name, ! empty( $_POST['save'] ) ) ) { // WPCS: input var okay, CSRF ok. WC_Admin_Settings::save(); } $setting_pages = \WC_Admin_Settings::get_settings_pages(); // Reinitialize all setting pages in case behavior is dependent on saved values. foreach ( $setting_pages as $key => $setting_page ) { $class_name = get_class( $setting_page ); $setting_pages[ $key ] = new $class_name(); } $data = Init::get_page_data( array(), $setting_pages ); return new \WP_REST_Response( array( 'status' => 'success', 'data' => $data, ) ); } catch ( \Exception $e ) { return new \WP_Error( 'woocommerce_settings_save_error', // translators: %s: error message. sprintf( __( 'Failed to save settings: %s', 'woocommerce' ), $e->getMessage() ), array( 'status' => 500 ) ); } } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function save_items_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'options', 'type' => 'object', 'properties' => array( 'options' => array( 'type' => 'array', 'description' => __( 'Array of options with associated values.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'tab' => array( 'type' => 'string', 'description' => __( 'Settings tab.', 'woocommerce' ), 'context' => array( 'view', 'edit' ), 'default' => 'general', ), 'section' => array( 'type' => 'string', 'description' => __( 'Settings section.', 'woocommerce' ), 'context' => array( 'view', 'edit' ), 'default' => '', ), ), ); return $schema; } } MarketingRecommendations.php 0000777 00000013705 15252174340 0012266 0 ustar 00 <?php /** * REST API MarketingRecommendations Controller * * Handles requests to /marketing/recommendations. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\MarketingRecommendations\Init as MarketingRecommendationsInit; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingRecommendations Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingRecommendations extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/recommendations'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], 'args' => [ 'category' => [ 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', 'enum' => [ 'channels', 'extensions' ], 'required' => true, ], ], ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); } /** * Check whether a given request has permission to view marketing recommendations. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view marketing channels.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Retrieves a collection of recommendations. * * @param WP_REST_Request $request Full details about the request. * * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { $category = $request->get_param( 'category' ); if ( 'channels' === $category ) { $items = MarketingRecommendationsInit::get_recommended_marketing_channels(); } elseif ( 'extensions' === $category ) { $items = MarketingRecommendationsInit::get_recommended_marketing_extensions_excluding_channels(); } else { return new WP_Error( 'woocommerce_rest_invalid_category', __( 'The specified category for recommendations is invalid. Allowed values: "channels", "extensions".', 'woocommerce' ), array( 'status' => 400 ) ); } $responses = []; foreach ( $items as $item ) { $response = $this->prepare_item_for_response( $item, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param array $item WordPress representation of the item. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function prepare_item_for_response( $item, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_recommendation', 'type' => 'object', 'properties' => [ 'title' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'url' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'direct_install' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'product' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'plugin' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'categories' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'string', ], ], 'subcategories' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], ], 'tags' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], ], ], ]; return $this->add_additional_fields_schema( $schema ); } } Reports/GenericController.php 0000777 00000023607 15252174340 0012355 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use WP_REST_Request; use WP_REST_Response; /** * {@see WC_REST_Reports_Controller WC REST API Reports Controller} extended to be shared as a generic base for all Analytics reports controllers. * * Handles pagination HTTP headers and links, basic, conventional params. * Does all the REST API plumbing as `WC_REST_Controller`. * * * Minimalistic example: * <pre><code class="language-php">class MyController extends GenericController { * /** Route of your new REST endpoint. */ * protected $rest_base = 'reports/my-thing'; * /** * * Provide JSON schema for the response item. * * @override WC_REST_Reports_Controller::get_item_schema() * */ * public function get_item_schema() { * $schema = array( * '$schema' => 'http://json-schema.org/draft-04/schema#', * 'title' => 'report_my_thing', * 'type' => 'object', * 'properties' => array( * 'product_id' => array( * 'type' => 'integer', * 'readonly' => true, * 'context' => array( 'view', 'edit' ), * 'description' => __( 'Product ID.', 'my_extension' ), * ), * ), * ); * // Add additional fields from `get_additional_fields` method and apply `woocommerce_rest_' . $schema['title'] . '_schema` filter. * return $this->add_additional_fields_schema( $schema ); * } * } * </code></pre> * * The above Controller will get the data from a {@see DataStore data store} registered as `$rest_base` (`reports/my-thing`). * (To change this behavior, override the `get_datastore_data()` method). * * To use the controller, please register it with the filter `woocommerce_admin_rest_controllers` filter. * * @extends WC_REST_Reports_Controller */ abstract class GenericController extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Add pagination headers and links. * * @param \WP_REST_Request $request Request data. * @param \WP_REST_Response|array $response Response data. * @param int $total Total results. * @param int $page Current page. * @param int $max_pages Total amount of pages. * @return \WP_REST_Response */ public function add_pagination_headers( $request, $response, int $total, int $page, int $max_pages ) { $response = rest_ensure_response( $response ); $response->header( 'X-WP-Total', $total ); $response->header( 'X-WP-TotalPages', $max_pages ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } /** * Get data from `{$this->rest_base}` store, based on the given query vars. * * @throws Exception When the data store is not found {@see WC_Data_Store WC_Data_Store}. * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $data_store = \WC_Data_Store::load( $this->rest_base ); return $data_store->get_data( $query_args ); } /** * Get the query params definition for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['after'] = array( 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['before'] = array( 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'date', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['force_cache_refresh'] = array( 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wp_validate_boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the report data. * * Prepares query params, fetches the report data from the data store, * prepares it for the response, and packs it into the convention-conforming response object. * * @throws \WP_Error When the queried data is invalid. * @param \WP_REST_Request $request Request data. * @return \WP_Error|\WP_REST_Response */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); $report_data = $this->get_datastore_data( $query_args ); if ( is_wp_error( $report_data ) ) { return $report_data; } if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) { return new \WP_Error( 'woocommerce_rest_reports_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) ); } $out_data = array(); foreach ( $report_data->data as $datum ) { $item = $this->prepare_item_for_response( $datum, $request ); $out_data[] = $this->prepare_response_for_collection( $item ); } return $this->add_pagination_headers( $request, $out_data, (int) $report_data->total, (int) $report_data->page_no, (int) $report_data->pages ); } /** * Prepare a report data item for serialization. * * This method is called by `get_items` to prepare a single report data item for serialization. * Calls `add_additional_fields_to_object` and `filter_response_by_context`, * then wpraps the data with `rest_ensure_response`. * * You can extend it to add or filter some fields. * * @override WP_REST_Posts_Controller::prepare_item_for_response() * * @param mixed $report_item Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report_item, $request ) { $data = $report_item; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. return rest_ensure_response( $data ); } /** * Maps query arguments from the REST request, to be used to query the datastore. * * `WP_REST_Request` does not expose a method to return all params covering defaults, * as it does for `$request['param']` accessor. * Therefore, we re-implement defaults resolution. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $args = wp_parse_args( array_intersect_key( $request->get_query_params(), $this->get_collection_params() ), $request->get_default_params() ); return $args; } /** * Apply a filter for custom orderby enum. * * @param array $orderby_enum An array of orderby enum options. * * @return array An array of filtered orderby enum options. * * @since 9.4.0 */ protected function apply_custom_orderby_filters( $orderby_enum ) { /** * Filter orderby query parameter enum. * * There was an initial concern about potential SQL injection with the custom orderby. * However, testing shows it is safely blocked by validation in the controller, * which results in an "Invalid parameter(s): orderby" error. * * Additionally, it's the responsibility of the merchant/developer to ensure the custom orderby is valid, * or a WordPress database error will occur for unknown columns. * * @since 9.4.0 * * @param array $orderby_enum The orderby query parameter enum. */ return apply_filters( "woocommerce_analytics_orderby_enum_{$this->rest_base}", $orderby_enum ); } } Reports/GenericStatsController.php 0000777 00000017772 15252174340 0013402 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use WP_Error; /** * Generic base for all stats controllers. * * {@see GenericController Generic Controller} extended to be shared as a generic base for all Analytics stats controllers. * * Besides the `GenericController` functionality, it adds conventional stats-specific collection params and item schema. * So, you may want to extend only your report-specific {@see get_item_properties_schema() get_item_properties_schema()}`. * It also uses the stats-specific {@see get_items() get_items()} method, * which packs report data into `totals` and `intervals`. * * * Minimalistic example: * <pre><code class="language-php">class StatsController extends GenericStatsController { * /** Route of your new REST endpoint. */ * protected $rest_base = 'reports/my-thing/stats'; * /** Define your proeprties schema. */ * protected function get_item_properties_schema() { * return array( * 'my_property' => array( * 'title' => __( 'My property', 'my-extension' ), * 'type' => 'integer', * 'readonly' => true, * 'context' => array( 'view', 'edit' ), * 'description' => __( 'Amazing thing.', 'my-extension' ), * 'indicator' => true, * ), * ); * } * /** Define overall schema. You can use the defaults, * * just remember to provide your title and call `add_additional_fields_schema` * * to run the filters * */ * public function get_item_schema() { * $schema = parent::get_item_schema(); * $schema['title'] = 'report_my_thing_stats'; * * return $this->add_additional_fields_schema( $schema ); * } * } * </code></pre> * * @extends GenericController */ abstract class GenericStatsController extends GenericController { /** * Get the query params definition for collections. * Adds `fields` & `intervals` to the generic list. * * @override GenericController::get_collection_params() * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['fields'] = array( 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', 'default' => 'week', 'enum' => array( 'hour', 'day', 'week', 'month', 'quarter', 'year', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ abstract protected function get_item_properties_schema(); /** * Get the Report's schema, conforming to JSON Schema. * * Please note that it does not call add_additional_fields_schema, * as you may want to update the `title` first. * * @return array */ public function get_item_schema() { $data_values = $this->get_item_properties_schema(); $segments = array( 'segments' => array( 'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'segment_id' => array( 'description' => __( 'Segment identificator.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $data_values, ), ), ), ), ); $totals = array_merge( $data_values, $segments ); return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), 'intervals' => array( 'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'interval' => array( 'description' => __( 'Type of interval.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ), 'date_start' => array( 'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_start_gmt' => array( 'description' => __( 'The date the report start, as GMT.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end' => array( 'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end_gmt' => array( 'description' => __( 'The date the report end, as GMT.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ), ), ), ); } /** * Get the report data. * * Prepares query params, fetches the report data from the data store, * prepares it for the response, and packs it into the convention-conforming response object. * * @override GenericController::get_items() * * @throws \WP_Error When the queried data is invalid. * @param \WP_REST_Request $request Request data. * @return \WP_REST_Response|\WP_Error */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); try { $report_data = $this->get_datastore_data( $query_args ); } catch ( ParameterException $e ) { return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); } $out_data = array( 'totals' => $report_data->totals ? get_object_vars( $report_data->totals ) : null, 'intervals' => array(), ); foreach ( $report_data->intervals as $interval_data ) { $item = $this->prepare_item_for_response( $interval_data, $request ); $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); } return $this->add_pagination_headers( $request, $out_data, (int) $report_data->total, (int) $report_data->page_no, (int) $report_data->pages ); } } Reports/ParameterException.php 0000777 00000000506 15252174340 0012525 0 ustar 00 <?php /** * WooCommerce Admin Input Parameter Exception Class * * Exception class thrown when user provides incorrect parameters. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * API\Reports\ParameterException class. */ class ParameterException extends \WC_Data_Exception {} Reports/Taxes/Query.php 0000777 00000003505 15252174340 0011121 0 ustar 00 <?php /** * Class for parameter-based Taxes Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'taxes' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Query * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_taxes_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_taxes_select_query', $results, $args ); } } Reports/Taxes/Stats/Query.php 0000777 00000003660 15252174340 0012221 0 ustar 00 <?php /** * Class for parameter-based Taxes Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Stats\Query * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get tax stats data based on the current query vars. * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_taxes_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_taxes_stats_select_query', $results, $args ); } } Reports/Taxes/Stats/Controller.php 0000777 00000014351 15252174340 0013236 0 ustar 00 <?php /** * REST API Reports taxes stats controller * * Handles requests to the /reports/taxes/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports taxes stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/taxes/stats'; /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_taxes_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Set the default results to 0 if API returns an empty array * * @internal * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->tax_codes = 0; $results->totals->total_tax = 0; $results->totals->order_tax = 0; $results->totals->shipping_tax = 0; $results->totals->orders = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['taxes'] = (array) $request['taxes']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'taxes-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'taxes-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_taxes_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'total_tax' => array( 'description' => __( 'Total tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'order_tax' => array( 'description' => __( 'Order tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'shipping_tax' => array( 'description' => __( 'Shipping tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'tax_codes' => array( 'description' => __( 'Amount of tax codes.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_taxes_stats'; return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'items_sold', 'total_sales', 'orders_count', 'products_count', ) ); $params['taxes'] = array( 'description' => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'tax_rate_id', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Taxes/Stats/DataStore.php 0000777 00000022562 15252174340 0013004 0 ustar 00 <?php /** * API\Reports\Taxes\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Taxes\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'taxes_stats'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'tax_codes' => 'intval', 'total_tax' => 'floatval', 'order_tax' => 'floatval', 'shipping_tax' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'taxes_stats'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'tax_codes' => 'COUNT(DISTINCT tax_rate_id) as tax_codes', 'total_tax' => 'SUM(total_tax) AS total_tax', 'order_tax' => 'SUM(order_tax) as order_tax', 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Updates the database query with parameters used for Taxes Stats report * * @see Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore::add_sql_query_params() * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $order_tax_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_tax_lookup_table ); $taxes_where_clause = ''; $order_status_filter = $this->get_status_subquery( $query_args ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$allowed_taxes` was prepared by get_filtered_ids above. */ $taxes_where_clause .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})"; /* phpcs:enable */ } if ( $order_status_filter ) { $taxes_where_clause .= " AND ( {$order_status_filter} )"; } $this->total_query->add_sql_clause( 'where', $taxes_where_clause ); $this->add_intervals_sql_params( $query_args, $order_tax_lookup_table ); $this->interval_query->add_sql_clause( 'where', $taxes_where_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); } /** * Get taxes associated with a store. * * @param array $args Array of args to filter the query by. Supports `include`. * @return array An array of all taxes. */ public static function get_taxes( $args ) { global $wpdb; $query = " SELECT tax_rate_id, tax_rate_country, tax_rate_state, tax_rate_name, tax_rate_priority FROM {$wpdb->prefix}woocommerce_tax_rates "; if ( ! empty( $args['include'] ) ) { $args['include'] = (array) $args['include']; /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $tax_placeholders = implode( ',', array_fill( 0, count( $args['include'] ), '%d' ) ); $query .= $wpdb->prepare( " WHERE tax_rate_id IN ({$tax_placeholders})", $args['include'] ); /* phpcs:enable */ } return $wpdb->get_results( $query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'tax_rate_id'; $defaults['taxes'] = array(); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $this->update_sql_query_params( $query_args ); $this->interval_query->add_sql_clause( 'join', $order_stats_join ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'join', $order_stats_join ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } } Reports/Taxes/Stats/Segmenter.php 0000777 00000013031 15252174340 0013036 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for order-related order-level segmenting query (e.g. tax_rate_id). * * @param string $lookup_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $lookup_table ) { $columns_mapping = array( 'tax_codes' => "COUNT(DISTINCT $lookup_table.tax_rate_id) as tax_codes", 'total_tax' => "SUM($lookup_table.total_tax) AS total_tax", 'order_tax' => "SUM($lookup_table.order_tax) as order_tax", 'shipping_tax' => "SUM($lookup_table.shipping_tax) as shipping_tax", 'orders_count' => "COUNT(DISTINCT $lookup_table.order_id) as orders_count", ); return $columns_mapping; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $segmenting_limit = ''; $limit_parts = explode( ',', $intervals_query['limit'] ); if ( 2 === count( $limit_parts ) ) { $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); } $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segmenting_where = ''; $segmenting_from = ''; $segments = array(); if ( 'tax_rate_id' === $this->query_args['segmentby'] ) { $tax_rate_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_select = $this->prepare_selections( $tax_rate_level_columns ); $this->report_columns = $tax_rate_level_columns; $segmenting_groupby = $table_name . '.tax_rate_id'; $segments = $this->get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } Reports/Taxes/DataStore.php 0000777 00000030016 15252174340 0011677 0 ustar 00 <?php /** * API\Reports\Taxes\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * API\Reports\Taxes\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'taxes'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'tax_rate_id' => 'intval', 'name' => 'strval', 'tax_rate' => 'floatval', 'country' => 'strval', 'state' => 'strval', 'priority' => 'intval', 'total_tax' => 'floatval', 'order_tax' => 'floatval', 'shipping_tax' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'taxes'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; $table_name = self::get_db_table_name(); // Using wp_woocommerce_tax_rates table limits the result to only the existing tax rates and // omits the historical records which differs from the purpose of wp_wc_order_tax_lookup table. // So in order to get the same data present in wp_woocommerce_tax_rates without breaking the // API contract the values are now retrieved from wp_woocommerce_order_items and wp_woocommerce_order_itemmeta. // And given that country, state and priority are not separate columns within the woocommerce_order_items, // a split to order_item_name column value is required to separate those values. This is not ideal, // but given this query is paginated and cached, then it is not a big deal. There is always room for // improvements here. $this->report_columns = array( 'tax_rate_id' => "{$table_name}.tax_rate_id", 'name' => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-2), '-', 1) as name", 'tax_rate' => 'CAST(itemmeta_rate_percent.meta_value AS DECIMAL(7,4)) as tax_rate', 'country' => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',1) as country", 'state' => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-3), '-', 1) as state", 'priority' => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-1) as priority", 'total_tax' => 'SUM(total_tax) as total_tax', 'order_tax' => 'SUM(order_tax) as order_tax', 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15 ); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Query arguments supplied by the user. * @param string $order_status_filter Order status subquery. */ protected function add_from_sql_params( $query_args, $order_status_filter ) { global $wpdb; $table_name = self::get_db_table_name(); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); } $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_items ON {$table_name}.order_id = {$wpdb->prefix}woocommerce_order_items.order_id AND {$wpdb->prefix}woocommerce_order_items.order_item_type = 'tax'" ); $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_itemmeta itemmeta_rate_id ON itemmeta_rate_id.order_item_id = {$wpdb->prefix}woocommerce_order_items.order_item_id AND itemmeta_rate_id.meta_key = 'rate_id'" ); $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_itemmeta itemmeta_rate_percent ON itemmeta_rate_percent.order_item_id = {$wpdb->prefix}woocommerce_order_items.order_item_id AND itemmeta_rate_percent.meta_key = 'rate_percent'" ); } /** * Updates the database query with parameters used for Taxes report: categories and order status. * * @see Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore::update_sql_query_params() * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_tax_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_tax_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $order_status_filter = $this->get_status_subquery( $query_args ); $this->add_from_sql_params( $query_args, $order_status_filter ); $this->subquery->add_sql_clause( 'where', "AND itemmeta_rate_id.meta_value = {$order_tax_lookup_table}.tax_rate_id" ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); $this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" ); } if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'tax_rate_id'; $defaults['taxes'] = array(); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $this->add_sql_query_params( $query_args ); $params = $this->get_limit_params( $query_args ); if ( isset( $query_args['taxes'] ) && is_array( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $total_results = count( $query_args['taxes'] ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); } else { $db_records_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- cache ok, DB call ok, unprepared SQL ok. "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); if ( in_array( $query_args['orderby'], array( 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', tax_rate_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $taxes_query = $this->subquery->get_query_statement(); $tax_data = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $taxes_query, ARRAY_A ); if ( null === $tax_data ) { return $data; } $tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data ); $data = (object) array( 'data' => $tax_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { global $wpdb; if ( 'tax_code' === $order_by ) { return "{$wpdb->prefix}woocommerce_order_items.order_item_name"; } elseif ( 'rate' === $order_by ) { return 'tax_rate'; } return $order_by; } /** * Create or update an entry in the wc_order_tax_lookup table for an order. * * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_taxes( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } $tax_items = $order->get_items( 'tax' ); $num_updated = 0; foreach ( $tax_items as $tax_item ) { $result = $wpdb->replace( self::get_db_table_name(), array( 'order_id' => $order->get_id(), 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), 'tax_rate_id' => $tax_item->get_rate_id(), 'shipping_tax' => $tax_item->get_shipping_tax_total(), 'order_tax' => $tax_item->get_tax_total(), 'total_tax' => (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total(), ), array( '%d', '%s', '%d', '%f', '%f', '%f', ) ); /** * Fires when tax's reports are updated. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_tax', $tax_item->get_rate_id(), $order->get_id() ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } return ( count( $tax_items ) === $num_updated ); } /** * Clean taxes data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when tax's reports are removed from database. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_tax', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { global $wpdb; $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'group_by', ", {$wpdb->prefix}woocommerce_order_items.order_item_name, itemmeta_rate_percent.meta_value" ); } } Reports/Taxes/Controller.php 0000777 00000016772 15252174340 0012151 0 ustar 00 <?php /** * REST API Reports taxes controller * * Handles requests to the /reports/taxes endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports taxes controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/taxes'; /** * Get data from `'taxes'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'taxes' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['taxes'] = $request['taxes']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_taxes', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'tax' => array( 'href' => rest_url( sprintf( '/%s/taxes/%d', $this->namespace, $object->tax_rate_id ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_taxes', 'type' => 'object', 'properties' => array( 'tax_rate_id' => array( 'description' => __( 'Tax rate ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Tax rate name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'tax_rate' => array( 'description' => __( 'Tax rate.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'country' => array( 'description' => __( 'Country / Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'state' => array( 'description' => __( 'State.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'priority' => array( 'description' => __( 'Priority.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_tax' => array( 'description' => __( 'Total tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'order_tax' => array( 'description' => __( 'Order tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'shipping_tax' => array( 'description' => __( 'Shipping tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'tax_rate_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'name', 'tax_rate_id', 'tax_code', 'rate', 'order_tax', 'total_tax', 'shipping_tax', 'orders_count', ) ); $params['taxes'] = array( 'description' => __( 'Limit result set to items assigned one or more tax rates.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { return array( 'tax_code' => __( 'Tax code', 'woocommerce' ), 'rate' => __( 'Rate', 'woocommerce' ), 'total_tax' => __( 'Total tax', 'woocommerce' ), 'order_tax' => __( 'Order tax', 'woocommerce' ), 'shipping_tax' => __( 'Shipping tax', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { return array( 'tax_code' => \WC_Tax::get_rate_code( (object) array( 'tax_rate_id' => $item['tax_rate_id'], 'tax_rate_country' => $item['country'], 'tax_rate_state' => $item['state'], 'tax_rate_name' => $item['name'], 'tax_rate_priority' => $item['priority'], ) ), 'rate' => $item['tax_rate'], 'total_tax' => self::csv_number_format( $item['total_tax'] ), 'order_tax' => self::csv_number_format( $item['order_tax'] ), 'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ), 'orders_count' => $item['orders_count'], ); } } Reports/ExportableTraits.php 0000777 00000001160 15252174340 0012217 0 ustar 00 <?php /** * REST API Reports exportable traits * * Collection of utility methods for exportable reports. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * ExportableTraits class. */ trait ExportableTraits { /** * Format numbers for CSV using store precision setting. * * @param string|float $value Numeric value. * @return string Formatted value. */ public static function csv_number_format( $value ) { $decimals = wc_get_price_decimals(); // See: @woocommerce/currency: getCurrencyFormatDecimal(). return number_format( $value, $decimals, '.', '' ); } } Reports/Categories/Query.php 0000777 00000003661 15252174340 0012125 0 ustar 00 <?php /** * Class for parameter-based Categories Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'order' => 'desc', * 'orderby' => 'items_sold', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Categories\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Categories\Query * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { const REPORT_NAME = 'report-categories'; /** * Valid fields for Categories report. * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get categories data based on the current query vars. * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_categories_query_args', $this->get_query_vars() ); $results = \WC_Data_Store::load( self::REPORT_NAME )->get_data( $args ); return apply_filters( 'woocommerce_analytics_categories_select_query', $results, $args ); } } Reports/Categories/DataStore.php 0000777 00000025306 15252174340 0012706 0 ustar 00 <?php /** * API\Reports\Categories\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Categories\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'categories'; /** * Order by setting used for sorting categories data. * * @var string */ private $order_by = ''; /** * Order setting used for sorting categories data. * * @var string */ private $order = ''; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'category_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'products_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'categories'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", 'products_count' => "COUNT(DISTINCT {$table_name}.product_id) as products_count", ); } /** * Return the database query with parameters used for Categories report: time span and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); // join wp_order_product_lookup_table with relationships and taxonomies // @todo How to handle custom product tables? $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id" ); // Adding this (inner) JOIN as a LEFT JOIN for ordering purposes. See comment in add_order_by_params(). $this->subquery->add_sql_clause( 'left_join', "JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id" ); $included_categories = $this->get_included_categories( $query_args ); if ( $included_categories ) { $this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_relationships}.term_taxonomy_id IN ({$included_categories})" ); // Limit is left out here so that the grouping in code by PHP can be applied correctly. // This also needs to be put after the term_taxonomy JOIN so that we can match the correct term name. $this->add_order_by_params( $query_args, 'outer', 'default_results.category_id' ); } else { $this->add_order_by_params( $query_args, 'inner', "{$wpdb->term_relationships}.term_taxonomy_id" ); } $this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery ); $this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_taxonomy}.taxonomy = 'product_cat'" ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; // Sanitize input: guarantee that the id cell in the join is quoted with backticks. $id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) ); $id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`'; $lookup_table = self::get_db_table_name(); $order_by_clause = $this->add_order_by_clause( $query_args, $this ); $this->add_orderby_order_clause( $query_args, $this ); if ( false !== strpos( $order_by_clause, '_terms' ) ) { $join = "JOIN {$wpdb->terms} AS _terms ON {$id_cell_identifier} = _terms.term_id"; if ( 'inner' === $from_arg ) { // Even though this is an (inner) JOIN, we're adding it as a `left_join` to // affect its order in the query statement. The SqlQuery::$sql_filters variable // determines the order in which joins are concatenated. // See: https://github.com/woocommerce/woocommerce-admin/blob/1f261998e7287b77bc13c3d4ee2e84b717da7957/src/API/Reports/SqlQuery.php#L46-L50. $this->subquery->add_sql_clause( 'left_join', $join ); } else { $this->add_sql_clause( 'join', $join ); } } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } if ( 'category' === $order_by ) { return '_terms.name'; } return $order_by; } /** * Returns an array of ids of included categories, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_categories_array( $query_args ) { if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) { return $query_args['category_includes']; } return array(); } /** * Returns the page of data according to page number and items per page. * * @param array $data Data to paginate. * @param integer $page_no Page number. * @param integer $items_per_page Number of items per page. * @return array */ protected function page_records( $data, $page_no, $items_per_page ) { $offset = ( $page_no - 1 ) * $items_per_page; return array_slice( $data, $offset, $items_per_page ); } /** * Enriches the category data. * * @param array $categories_data Categories data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$categories_data, $query_args ) { foreach ( $categories_data as $key => $category_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $extended_info['name'] = get_the_category_by_ID( $category_data['category_id'] ); } $categories_data[ $key ]['extended_info'] = $extended_info; } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @see get_data * @override ReportsDataStore::get_noncached_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); $included_categories = $this->get_included_categories_array( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_categories ) > 0 ) { $fields = $this->get_fields( $query_args ); $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); $this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.category_id = {$table_name}.category_id" ); $categories_query = $this->get_query_statement(); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $categories_query = $this->subquery->get_query_statement(); } $categories_data = $wpdb->get_results( $categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $categories_data ) { return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) ); } $record_count = count( $categories_data ); $total_pages = (int) ceil( $record_count / $query_args['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] ); $this->include_extended_info( $categories_data, $query_args ); $categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data ); $data = (object) array( 'data' => $categories_data, 'total' => $record_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Initialize query objects. * * @override ReportsDataStore::initialize_queries() */ protected function initialize_queries() { global $wpdb; $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', "{$wpdb->term_taxonomy}.term_id as category_id," ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', "{$wpdb->term_taxonomy}.term_id" ); } } Reports/Categories/Controller.php 0000777 00000021260 15252174340 0013136 0 ustar 00 <?php /** * REST API Reports categories controller * * Handles requests to the /reports/categories endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports categories controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/categories'; /** * Get data from `'categories'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'categories' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['extended_info'] = $request['extended_info']; $args['category_includes'] = (array) $request['categories']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_categories', $response, $report, $request ); } /** * Prepare links for the request. * * @param \Automattic\WooCommerce\Admin\API\Reports\GenericQuery $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'category' => array( 'href' => rest_url( sprintf( '/%s/products/categories/%d', $this->namespace, $object['category_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_categories', 'type' => 'object', 'properties' => array( 'category_id' => array( 'description' => __( 'Category ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'items_sold' => array( 'description' => __( 'Amount of items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'net_revenue' => array( 'description' => __( 'Total sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'products_count' => array( 'description' => __( 'Amount of products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Category name.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'category_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'category_id', 'items_sold', 'net_revenue', 'orders_count', 'products_count', 'category', ) ); $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', 'default' => 'week', 'enum' => array( 'hour', 'day', 'week', 'month', 'quarter', 'year', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['categories'] = array( 'description' => __( 'Limit result set to all items that have the specified term assigned in the categories taxonomy.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each category to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'category' => __( 'Category', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'Net Revenue', 'woocommerce' ), 'products_count' => __( 'Products', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); /** * Filter to add or remove column names from the categories report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_categories_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'category' => $item['extended_info']['name'], 'items_sold' => $item['items_sold'], 'net_revenue' => $item['net_revenue'], 'products_count' => $item['products_count'], 'orders_count' => $item['orders_count'], ); /** * Filter to prepare extra columns in the export item for the * categories export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_categories_prepare_export_item', $export_item, $item ); } } Reports/ExportableInterface.php 0000777 00000001160 15252174340 0012651 0 ustar 00 <?php /** * Reports Exportable Controller Interface */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WooCommerce Reports exportable controller interface. * * @since 3.5.0 */ interface ExportableInterface { /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns(); /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Value. */ public function prepare_item_for_export( $item ); } Reports/DataStoreInterface.php 0000777 00000000621 15252174340 0012433 0 ustar 00 <?php /** * Reports Data Store Interface */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WooCommerce Reports data store interface. * * @since 3.5.0 */ interface DataStoreInterface { /** * Get the data based on args. * * @param array $args Query parameters. * @return stdClass|WP_Error */ public function get_data( $args ); } Reports/Controller.php 0000777 00000015417 15252174340 0011060 0 ustar 00 <?php /** * REST API Reports controller extended to handle requests to the reports endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * Reports controller class. * * Controller that handles the endpoint that returns all available analytics endpoints. * * @internal * @extends GenericController */ class Controller extends GenericController { use OrderAwareControllerTrait; /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $data = array(); $reports = array( array( 'slug' => 'performance-indicators', 'description' => __( 'Batch endpoint for getting specific performance indicators from `stats` endpoints.', 'woocommerce' ), ), array( 'slug' => 'revenue/stats', 'description' => __( 'Stats about revenue.', 'woocommerce' ), ), array( 'slug' => 'orders/stats', 'description' => __( 'Stats about orders.', 'woocommerce' ), ), array( 'slug' => 'products', 'description' => __( 'Products detailed reports.', 'woocommerce' ), ), array( 'slug' => 'products/stats', 'description' => __( 'Stats about products.', 'woocommerce' ), ), array( 'slug' => 'variations', 'description' => __( 'Variations detailed reports.', 'woocommerce' ), ), array( 'slug' => 'variations/stats', 'description' => __( 'Stats about variations.', 'woocommerce' ), ), array( 'slug' => 'categories', 'description' => __( 'Product categories detailed reports.', 'woocommerce' ), ), array( 'slug' => 'categories/stats', 'description' => __( 'Stats about product categories.', 'woocommerce' ), ), array( 'slug' => 'coupons', 'description' => __( 'Coupons detailed reports.', 'woocommerce' ), ), array( 'slug' => 'coupons/stats', 'description' => __( 'Stats about coupons.', 'woocommerce' ), ), array( 'slug' => 'taxes', 'description' => __( 'Taxes detailed reports.', 'woocommerce' ), ), array( 'slug' => 'taxes/stats', 'description' => __( 'Stats about taxes.', 'woocommerce' ), ), array( 'slug' => 'downloads', 'description' => __( 'Product downloads detailed reports.', 'woocommerce' ), ), array( 'slug' => 'downloads/files', 'description' => __( 'Product download files detailed reports.', 'woocommerce' ), ), array( 'slug' => 'downloads/stats', 'description' => __( 'Stats about product downloads.', 'woocommerce' ), ), array( 'slug' => 'customers', 'description' => __( 'Customers detailed reports.', 'woocommerce' ), ), array( 'slug' => 'customers/stats', 'description' => __( 'Stats about groups of customers.', 'woocommerce' ), ), ); /** * Filter the list of allowed reports, so that data can be loaded from third party extensions in addition to WooCommerce core. * Array items should be in format of array( 'slug' => 'downloads/stats', 'description' => '', * 'url' => '', and 'path' => '/wc-ext/v1/...'. * * @param array $endpoints The list of allowed reports.. */ $reports = apply_filters( 'woocommerce_admin_reports', $reports ); foreach ( $reports as $report ) { // Silently skip non-compliant reports. Like the ones for WC_Admin_Reports::get_reports(). if ( empty( $report['slug'] ) ) { continue; } if ( empty( $report['path'] ) ) { $report['path'] = '/' . $this->namespace . '/reports/' . $report['slug']; } // Allows a different admin page to be loaded here, // or allows an empty url if no report exists for a set of performance indicators. if ( ! isset( $report['url'] ) ) { if ( '/stats' === substr( $report['slug'], -6 ) ) { $url_slug = substr( $report['slug'], 0, -6 ); } else { $url_slug = $report['slug']; } $report['url'] = '/analytics/' . $url_slug; } $item = $this->prepare_item_for_response( (object) $report, $request ); $data[] = $this->prepare_response_for_collection( $item ); } return rest_ensure_response( $data ); } /** * Prepare a report object for serialization. * * @param stdClass $report Report data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = array( 'slug' => $report->slug, 'description' => $report->description, 'path' => $report->path, ); // Wrap the data in a response object. $response = parent::prepare_item_for_response( $data, $request ); $response->add_links( array( 'self' => array( 'href' => rest_url( $report->path ), ), 'report' => array( 'href' => $report->url, ), 'collection' => array( 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @override WP_REST_Controller::get_item_schema() * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'An alphanumeric identifier for the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'description' => array( 'description' => __( 'A human-readable description of the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'path' => array( 'description' => __( 'API path.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), ); } } Reports/Customers/DataStore.php 0000777 00000105570 15252174340 0012607 0 ustar 00 <?php /** * Admin\API\Reports\Customers\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Utilities\OrderUtil; /** * Admin\API\Reports\Customers\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_customer_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'customers'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'id' => 'intval', 'user_id' => 'intval', 'orders_count' => 'intval', 'total_spend' => 'floatval', 'avg_order_value' => 'floatval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'customers'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; $table_name = self::get_db_table_name(); $orders_count = 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )'; $total_spend = 'SUM( total_sales )'; $this->report_columns = array( 'id' => "{$table_name}.customer_id as id", 'user_id' => 'user_id', 'username' => 'username', 'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL? 'first_name' => 'first_name', 'last_name' => 'last_name', 'email' => 'email', 'country' => 'country', 'city' => 'city', 'state' => 'state', 'postcode' => 'postcode', 'date_registered' => 'date_registered', // Use single quotes for string literals to ensure compatibility with sql_mode=ANSI_QUOTES. 'date_last_active' => "IF( date_last_active <= '0000-00-00 00:00:00', NULL, date_last_active ) AS date_last_active", 'date_last_order' => "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order", 'orders_count' => "{$orders_count} as orders_count", 'total_spend' => "{$total_spend} as total_spend", 'avg_order_value' => "CASE WHEN {$orders_count} = 0 THEN NULL ELSE {$total_spend} / {$orders_count} END AS avg_order_value", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_new_customer', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'woocommerce_update_customer', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'profile_update', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'added_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 ); add_action( 'updated_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 ); add_action( 'delete_user', array( __CLASS__, 'delete_customer_by_user_id' ) ); add_action( 'remove_user_from_blog', array( __CLASS__, 'delete_customer_by_user_id' ) ); add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'anonymize_customer' ) ); add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15, 2 ); } /** * Sync customers data after an order was deleted. * * When an order is deleted, the customer record is deleted from the * table if the customer has no other orders. * * @param int $order_id Order ID. * @param int $customer_id Customer ID. */ public static function sync_on_order_delete( $order_id, $customer_id ) { $customer_id = absint( $customer_id ); if ( 0 === $customer_id ) { return; } // Calculate the amount of orders remaining for this customer. $order_count = self::get_order_count( $customer_id ); if ( 0 === $order_count ) { self::delete_customer( $customer_id ); } } /** * Sync customers data after an order was updated. * * Only updates customer if it is the customers last order. * * @param int $post_id of order. * @return true|-1 */ public static function sync_order_customer( $post_id ) { global $wpdb; if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return -1; } $order = wc_get_order( $post_id ); $customer_id = self::get_existing_customer_id_from_order( $order ); if ( false === $customer_id ) { return -1; } $last_order = self::get_last_order( $customer_id ); if ( ! $last_order || $order->get_id() !== $last_order->get_id() ) { return -1; } list($data, $format) = self::get_customer_order_data_and_format( $order ); $result = $wpdb->update( self::get_db_table_name(), $data, array( 'customer_id' => $customer_id ), $format ); /** * Fires when a customer is updated. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_update_customer', $customer_id ); return 1 === $result; } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. Overridden here to allow multiple direction * clauses. * * @since 10.5.0 * @param array $query_args Parameters supplied by the user. * @return void */ protected function add_order_by_sql_params( $query_args ) { $order_by_clause = $this->normalize_order_by_clause( $query_args['orderby'] ?? 'date_registered', $query_args['order'] ?? 'desc' ); $this->clear_sql_clause( 'order_by' ); $this->add_sql_clause( 'order_by', $order_by_clause ); } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * Handles both order_by and direction. * * @since 10.5.0 * @param string $order_by Sorting criterion. * @param string $order Order direction. * @return string */ protected function normalize_order_by_clause( $order_by, $order = 'desc' ) { $order_by = esc_sql( $order_by ); $order = strtolower( $order ) === 'asc' ? 'ASC' : 'DESC'; $order_by_clause = ''; if ( 'location' === $order_by ) { $order_by_clause = "state {$order}, country {$order}"; } else { $order_by_clause = "{$order_by} {$order}"; } return $order_by_clause; } /** * Fills WHERE clause of SQL request with date-related constraints. * * @override ReportsDataStore::add_time_period_sql_params() * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_time_period_sql_params( $query_args, $table_name ) { global $wpdb; $this->clear_sql_clause( array( 'where', 'where_time', 'having' ) ); $date_param_mapping = array( 'registered' => array( 'clause' => 'where', 'column' => $table_name . '.date_registered', ), 'order' => array( 'clause' => 'where', 'column' => $wpdb->prefix . 'wc_order_stats.date_created', ), 'last_active' => array( 'clause' => 'where', 'column' => $table_name . '.date_last_active', ), 'last_order' => array( 'clause' => 'having', 'column' => "MAX( {$wpdb->prefix}wc_order_stats.date_created )", ), ); $match_operator = $this->get_match_operator( $query_args ); $where_time_clauses = array(); $having_time_clauses = array(); foreach ( $date_param_mapping as $query_param => $param_info ) { $subclauses = array(); $before_arg = $query_param . '_before'; $after_arg = $query_param . '_after'; $column_name = $param_info['column']; if ( ! empty( $query_args[ $before_arg ] ) ) { $datetime = new \DateTime( $query_args[ $before_arg ] ); $datetime_str = $datetime->format( TimeInterval::$sql_datetime_format ); $subclauses[] = "{$column_name} <= '$datetime_str'"; } if ( ! empty( $query_args[ $after_arg ] ) ) { $datetime = new \DateTime( $query_args[ $after_arg ] ); $datetime_str = $datetime->format( TimeInterval::$sql_datetime_format ); $subclauses[] = "{$column_name} >= '$datetime_str'"; } if ( $subclauses && ( 'where' === $param_info['clause'] ) ) { $where_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } if ( $subclauses && ( 'having' === $param_info['clause'] ) ) { $having_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } } if ( $where_time_clauses ) { $this->subquery->add_sql_clause( 'where_time', 'AND ' . implode( " {$match_operator} ", $where_time_clauses ) ); } if ( $having_time_clauses ) { $this->subquery->add_sql_clause( 'having', 'AND ' . implode( " {$match_operator} ", $having_time_clauses ) ); } } /** * Updates the database query with parameters used for Customers report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $customer_lookup_table = self::get_db_table_name(); $order_stats_table_name = $wpdb->prefix . 'wc_order_stats'; $this->add_time_period_sql_params( $query_args, $customer_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id" ); $match_operator = $this->get_match_operator( $query_args ); $where_clauses = array(); $having_clauses = array(); $exact_match_params = array( 'name', 'username', 'email', 'country', ); foreach ( $exact_match_params as $exact_match_param ) { if ( ! empty( $query_args[ $exact_match_param . '_includes' ] ) ) { $exact_match_arguments = $query_args[ $exact_match_param . '_includes' ]; $exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) ); $included = implode( "','", $exact_match_arguments_escaped ); // 'country_includes' is a list of country codes, the others will be a list of customer ids. $table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id'; $where_clauses[] = "{$customer_lookup_table}.{$table_column} IN ('{$included}')"; } if ( ! empty( $query_args[ $exact_match_param . '_excludes' ] ) ) { $exact_match_arguments = $query_args[ $exact_match_param . '_excludes' ]; $exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) ); $excluded = implode( "','", $exact_match_arguments_escaped ); // 'country_includes' is a list of country codes, the others will be a list of customer ids. $table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id'; $where_clauses[] = "{$customer_lookup_table}.{$table_column} NOT IN ('{$excluded}')"; } } $search_params = array( 'name', 'username', 'email', 'all', ); if ( ! empty( $query_args['search'] ) ) { $name_like = '%' . $wpdb->esc_like( $query_args['search'] ) . '%'; if ( empty( $query_args['searchby'] ) || 'name' === $query_args['searchby'] || ! in_array( $query_args['searchby'], $search_params, true ) ) { $searchby = "CONCAT_WS( ' ', first_name, last_name )"; } elseif ( 'all' === $query_args['searchby'] ) { $searchby = "CONCAT_WS( ' ', first_name, last_name, username, email )"; } else { $searchby = $query_args['searchby']; } $where_clauses[] = $wpdb->prepare( "{$searchby} LIKE %s", $name_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } $filter_empty_params = array( 'email', 'name', 'country', 'city', 'state', 'postcode', ); if ( ! empty( $query_args['filter_empty'] ) ) { $fields_to_filter_by = array_intersect( $query_args['filter_empty'], $filter_empty_params ); if ( in_array( 'name', $fields_to_filter_by, true ) ) { $fields_to_filter_by = array_diff( $fields_to_filter_by, array( 'name' ) ); $fields_to_filter_by[] = "CONCAT_WS( ' ', first_name, last_name )"; } $fields_with_not_condition = array_map( function ( $field ) { return $field . ' <> \'\''; }, $fields_to_filter_by ); $where_clauses[] = '(' . implode( ' AND ', $fields_with_not_condition ) . ')'; } // Allow a list of customer IDs to be specified. if ( ! empty( $query_args['customers'] ) ) { $included_customers = $this->get_filtered_ids( $query_args, 'customers' ); $where_clauses[] = "{$customer_lookup_table}.customer_id IN ({$included_customers})"; } // Allow a list of user IDs to be specified. if ( ! empty( $query_args['users'] ) ) { $included_users = $this->get_filtered_ids( $query_args, 'users' ); $where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})"; } // Allow a list of locations to be specified (includes). if ( ! empty( $query_args['location_includes'] ) ) { $location_clause = $this->build_location_filter_clause( $query_args['location_includes'], true ); if ( '' !== $location_clause ) { $where_clauses[] = $location_clause; } } // Allow a list of locations to be excluded. if ( ! empty( $query_args['location_excludes'] ) ) { $location_clause = $this->build_location_filter_clause( $query_args['location_excludes'], false ); if ( '' !== $location_clause ) { $where_clauses[] = $location_clause; } } // Filter by user type. if ( ! empty( $query_args['user_type'] ) && 'all' !== $query_args['user_type'] ) { $user_type = $query_args['user_type']; $where_clauses[] = "{$customer_lookup_table}.user_id IS " . ( 'registered' === $user_type ? 'NOT NULL' : 'NULL' ); } $numeric_params = array( 'orders_count' => array( 'column' => 'COUNT( order_id )', 'format' => '%d', ), 'total_spend' => array( 'column' => 'SUM( total_sales )', 'format' => '%f', ), 'avg_order_value' => array( 'column' => '( SUM( total_sales ) / COUNT( order_id ) )', 'format' => '%f', ), ); foreach ( $numeric_params as $numeric_param => $param_info ) { $subclauses = array(); $min_param = $numeric_param . '_min'; $max_param = $numeric_param . '_max'; $or_equal = isset( $query_args[ $min_param ] ) && isset( $query_args[ $max_param ] ) ? '=' : ''; if ( isset( $query_args[ $min_param ] ) ) { $subclauses[] = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "{$param_info['column']} >{$or_equal} {$param_info['format']}", $query_args[ $min_param ] ); } if ( isset( $query_args[ $max_param ] ) ) { $subclauses[] = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "{$param_info['column']} <{$or_equal} {$param_info['format']}", $query_args[ $max_param ] ); } if ( $subclauses ) { $having_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } } if ( $where_clauses ) { $preceding_match = empty( $this->get_sql_clause( 'where_time' ) ) ? ' AND ' : " {$match_operator} "; $this->subquery->add_sql_clause( 'where', $preceding_match . implode( " {$match_operator} ", $where_clauses ) ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'left_join', "AND ( {$order_status_filter} )" ); } if ( $having_clauses ) { $preceding_match = empty( $this->get_sql_clause( 'having' ) ) ? ' AND ' : " {$match_operator} "; $this->subquery->add_sql_clause( 'having', $preceding_match . implode( " {$match_operator} ", $having_clauses ) ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'date_registered'; $defaults['order_before'] = TimeInterval::default_before(); $defaults['order_after'] = TimeInterval::default_after(); return $defaults; } /** * Returns an existing customer ID for an order if one exists. * * @param object $order WC Order. * @return int|bool */ public static function get_existing_customer_id_from_order( $order ) { global $wpdb; if ( ! is_a( $order, 'WC_Order' ) ) { return false; } $user_id = $order->get_customer_id(); if ( 0 === $user_id ) { $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order->get_id() ) ); if ( $customer_id ) { return $customer_id; } $email = $order->get_billing_email( 'edit' ); if ( $email ) { return self::get_customer_id_by_email( $email ); } else { return false; } } else { return self::get_customer_id_by_user_id( $user_id ); } } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $sql_query_params = $this->add_sql_query_params( $query_args ); $count_query = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) as tt "; $db_records_count = (int) $wpdb->get_var( $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $params = $this->get_limit_params( $query_args ); $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); // For aggregated fields, ensure deterministic ordering by including GROUP BY field. $order_by = $this->get_sql_clause( 'order_by' ); $aggregated_fields = array( 'orders_count', 'total_spend', 'avg_order_value' ); $has_aggregated_field = false; foreach ( $aggregated_fields as $field ) { if ( false !== strpos( $order_by, $field ) ) { $has_aggregated_field = true; break; } } if ( $has_aggregated_field ) { $customer_lookup_table = self::get_db_table_name(); $this->subquery->add_sql_clause( 'order_by', $order_by . ", {$customer_lookup_table}.customer_id" ); } else { $this->subquery->add_sql_clause( 'order_by', $order_by ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $customer_data = $wpdb->get_results( $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $customer_data ) { return $data; } $customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data ); $data = (object) array( 'data' => $customer_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Get or create a customer from a given order. * * @param object $order WC Order. * @return int|bool */ public static function get_or_create_customer_from_order( $order ) { if ( ! $order ) { return false; } global $wpdb; if ( ! is_a( $order, 'WC_Order' ) ) { return false; } $returning_customer_id = self::get_existing_customer_id_from_order( $order ); if ( $returning_customer_id ) { return $returning_customer_id; } list($data, $format) = self::get_customer_order_data_and_format( $order ); $result = $wpdb->insert( self::get_db_table_name(), $data, $format ); $customer_id = $wpdb->insert_id; /** * Fires when a new report customer is created. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_new_customer', $customer_id ); return $result ? $customer_id : false; } /** * Returns a data object and format object of the customers data coming from the order. * * @param object $order WC_Order where we get customer info from. * @param object|null $customer_user WC_Customer registered customer WP user. * @return array ($data, $format) */ public static function get_customer_order_data_and_format( $order, $customer_user = null ) { $data = array( 'first_name' => $order->get_customer_first_name(), 'last_name' => $order->get_customer_last_name(), 'email' => $order->get_billing_email( 'edit' ), 'city' => $order->get_billing_city( 'edit' ), 'state' => $order->get_billing_state( 'edit' ), 'postcode' => $order->get_billing_postcode( 'edit' ), 'country' => $order->get_billing_country( 'edit' ), 'date_last_active' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ), ); $format = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', ); // Add registered customer data. if ( 0 !== $order->get_user_id() ) { $user_id = $order->get_user_id(); if ( is_null( $customer_user ) ) { $customer_user = new \WC_Customer( $user_id ); } // Set email as customer email instead of Order Billing Email if we have a customer. $data['email'] = $customer_user->get_email( 'edit' ); // Adding other relevant customer data. $data['user_id'] = $user_id; $data['username'] = $customer_user->get_username( 'edit' ); $data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null; $format[] = '%d'; $format[] = '%s'; $format[] = '%s'; } return array( $data, $format ); } /** * Retrieve a guest ID (when user_id is null) by email. * * @param string $email Email address. * @return false|array Customer array if found, boolean false if not. */ public static function get_guest_id_by_email( $email ) { global $wpdb; $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE email = %s AND user_id IS NULL LIMIT 1", $email ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve a customer ID by email address, regardless of user registration status. * Prioritizes registered customers over guest customers when both exist. * * @param string $email Email address. * @return false|int Customer ID if found, boolean false if not. */ public static function get_customer_id_by_email( $email ) { global $wpdb; if ( empty( $email ) || ! is_email( $email ) ) { return false; } $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE email = %s ORDER BY user_id IS NOT NULL DESC LIMIT 1", $email ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve a registered customer row id by user_id. * * @param string|int $user_id User ID. * @return false|int Customer ID if found, boolean false if not. */ public static function get_customer_id_by_user_id( $user_id ) { global $wpdb; $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE user_id = %d LIMIT 1", $user_id ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve the last order made by a customer. * * @param int $customer_id Customer ID. * @return object WC_Order|false. */ public static function get_last_order( $customer_id ) { global $wpdb; $orders_table = $wpdb->prefix . 'wc_order_stats'; $last_order = $wpdb->get_var( $wpdb->prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, date_created_gmt FROM {$orders_table} WHERE customer_id = %d ORDER BY date_created_gmt DESC, order_id DESC LIMIT 1", // phpcs:enable $customer_id ) ); if ( ! $last_order ) { return false; } return wc_get_order( absint( $last_order ) ); } /** * Retrieve the oldest orders made by a customer. * * @param int $customer_id Customer ID. * @return array Orders. */ public static function get_oldest_orders( $customer_id ) { global $wpdb; $orders_table = $wpdb->prefix . 'wc_order_stats'; $excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() ); $excluded_statuses_condition = ''; if ( ! empty( $excluded_statuses ) ) { $excluded_statuses_str = implode( "','", $excluded_statuses ); $excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')"; } return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2", $customer_id ) ); } /** * Retrieve the amount of orders made by a customer. * * @param int $customer_id Customer ID. * @return int|null Amount of orders for customer or null on failure. */ public static function get_order_count( $customer_id ) { global $wpdb; $customer_id = absint( $customer_id ); if ( 0 === $customer_id ) { return null; } $result = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT( order_id ) FROM {$wpdb->prefix}wc_order_stats WHERE customer_id = %d", $customer_id ) ); if ( is_null( $result ) ) { return null; } return (int) $result; } /** * Update the database with customer data. * * @param int $user_id WP User ID to update customer data for. * @return int|bool|null Number or rows modified or false on failure. */ public static function update_registered_customer( $user_id ) { global $wpdb; $customer = new \WC_Customer( $user_id ); if ( ! self::is_valid_customer( $user_id ) ) { return false; } $first_name = $customer->get_first_name(); $last_name = $customer->get_last_name(); if ( empty( $first_name ) ) { $first_name = $customer->get_billing_first_name(); } if ( empty( $last_name ) ) { $last_name = $customer->get_billing_last_name(); } $last_active = $customer->get_meta( 'wc_last_active', true, 'edit' ); $data = array( 'user_id' => $user_id, 'username' => $customer->get_username( 'edit' ), 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $customer->get_email( 'edit' ), 'city' => $customer->get_billing_city( 'edit' ), 'state' => $customer->get_billing_state( 'edit' ), 'postcode' => $customer->get_billing_postcode( 'edit' ), 'country' => $customer->get_billing_country( 'edit' ), 'date_registered' => $customer->get_date_created( 'edit' ) ? $customer->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null, 'date_last_active' => $last_active ? gmdate( 'Y-m-d H:i:s', $last_active ) : null, ); $format = array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', ); $customer_id = self::get_customer_id_by_user_id( $user_id ); if ( $customer_id ) { // Preserve customer_id for existing user_id. $data['customer_id'] = $customer_id; $format[] = '%d'; } $results = $wpdb->replace( self::get_db_table_name(), $data, $format ); /** * Fires when customser's reports are updated. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_update_customer', $customer_id ); ReportsCache::invalidate(); return $results; } /** * Update the database if the "last active" meta value was changed. * Function expects to be hooked into the `added_user_meta` and `updated_user_meta` actions. * * @param int $meta_id ID of updated metadata entry. * @param int $user_id ID of the user being updated. * @param string $meta_key Meta key being updated. */ public static function update_registered_customer_via_last_active( $meta_id, $user_id, $meta_key ) { if ( 'wc_last_active' === $meta_key ) { self::update_registered_customer( $user_id ); } } /** * Check if a user ID is a valid customer or other user role with past orders. * * @param int $user_id User ID. * @return bool */ protected static function is_valid_customer( $user_id ) { $user = new \WP_User( $user_id ); if ( (int) $user_id !== $user->ID ) { return false; } /** * Filter the customer roles, used to check if the user is a customer. * * @param array List of customer roles. * @since 4.0.0 */ $customer_roles = (array) apply_filters( 'woocommerce_analytics_customer_roles', array( 'customer' ) ); if ( empty( $user->roles ) || empty( array_intersect( $user->roles, $customer_roles ) ) ) { return false; } return true; } /** * Delete a customer lookup row. * * @param int $customer_id Customer ID. */ public static function delete_customer( $customer_id ) { global $wpdb; $customer_id = (int) $customer_id; $num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'customer_id' => $customer_id ) ); if ( $num_deleted ) { /** * Fires when a customer is deleted. * * @param int $order_id Order ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_delete_customer', $customer_id ); ReportsCache::invalidate(); } } /** * Delete a customer lookup row by WordPress User ID. * * @param int $user_id WordPress User ID. */ public static function delete_customer_by_user_id( $user_id ) { global $wpdb; if ( (int) $user_id < 1 || doing_action( 'wp_uninitialize_site' ) ) { // Skip the deletion. return; } $user_id = (int) $user_id; $num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'user_id' => $user_id ) ); if ( $num_deleted ) { ReportsCache::invalidate(); } } /** * Anonymize the customer data for a single order. * * @internal * @param int|WC_Order $order Order instance or ID. * @return void */ public static function anonymize_customer( $order ) { global $wpdb; if ( ! is_object( $order ) ) { $order = wc_get_order( absint( $order ) ); } $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order->get_id() ) ); if ( ! $customer_id ) { return; } // Long form query because $wpdb->update rejects [deleted]. $deleted_text = __( '[deleted]', 'woocommerce' ); $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}wc_customer_lookup SET user_id = NULL, username = %s, first_name = %s, last_name = %s, email = %s, country = '', postcode = %s, city = %s, state = %s WHERE customer_id = %d", array( $deleted_text, $deleted_text, $deleted_text, 'deleted@site.invalid', $deleted_text, $deleted_text, $deleted_text, $customer_id, ) ) ); // If the customer row was anonymized, flush the cache. if ( $updated ) { ReportsCache::invalidate(); } } /** * Build location filter SQL clause for includes or excludes. * * @since 10.5.0 * @param string $locations_string Comma-separated list of locations (e.g., "US:CA,US:NY,GB"). * @param bool $is_include True for IN clause, false for NOT IN clause. * @return string SQL WHERE clause condition. */ protected function build_location_filter_clause( $locations_string, $is_include = true ) { $customer_lookup_table = self::get_db_table_name(); $locations_array = explode( ',', $locations_string ); $country_state_pairs = array(); $countries = array(); foreach ( $locations_array as $location ) { $location = trim( $location ); if ( empty( $location ) ) { continue; } if ( false !== strpos( $location, ':' ) ) { $parts = explode( ':', $location ); if ( 2 === count( $parts ) ) { $country_state_pairs[] = array( 'country' => esc_sql( $parts[0] ), 'state' => esc_sql( $parts[1] ), ); } } else { $countries[] = esc_sql( $location ); } } $conditions = array(); // Build country:state pair conditions. if ( ! empty( $country_state_pairs ) ) { $pair_conditions = array(); foreach ( $country_state_pairs as $pair ) { if ( $is_include ) { $pair_conditions[] = "({$customer_lookup_table}.country = '{$pair['country']}' AND {$customer_lookup_table}.state = '{$pair['state']}')"; } else { $pair_conditions[] = "({$customer_lookup_table}.country != '{$pair['country']}' OR {$customer_lookup_table}.state != '{$pair['state']}')"; } } $pair_connector = $is_include ? ' OR ' : ' AND '; $conditions[] = '(' . implode( $pair_connector, $pair_conditions ) . ')'; } // Build country-only conditions. if ( ! empty( $countries ) ) { $operator = $is_include ? 'IN' : 'NOT IN'; $conditions[] = "{$customer_lookup_table}.country {$operator} ('" . implode( "','", $countries ) . "')"; } if ( empty( $conditions ) ) { return ''; } // Combine conditions with OR for includes, AND for excludes. $connector = $is_include ? ' OR ' : ' AND '; return '(' . implode( $connector, $conditions ) . ')'; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $table_name = self::get_db_table_name(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', $table_name ); $this->subquery->add_sql_clause( 'select', "{$table_name}.customer_id" ); $this->subquery->add_sql_clause( 'group_by', "{$table_name}.customer_id" ); } } Reports/Customers/Query.php 0000777 00000002366 15252174340 0012025 0 ustar 00 <?php /** * Class for parameter-based Customers Report querying * * Example usage: * $args = array( * 'registered_before' => '2018-07-19 00:00:00', * 'registered_after' => '2018-07-05 00:00:00', * 'page' => 2, * 'avg_order_value_min' => 100, * 'country' => 'GB', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Customers\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'customers'; /** * Valid fields for Customers report. * * @return array */ protected function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date_registered', 'fields' => '*', ); } } Reports/Customers/Controller.php 0000777 00000062223 15252174340 0013041 0 ustar 00 <?php /** * REST API Reports customers controller * * Handles requests to the /reports/customers endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * REST API Reports customers controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/customers'; /** * Get data from Customers\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['registered_before'] = $request['registered_before']; $args['registered_after'] = $request['registered_after']; $args['order_before'] = $request['before']; $args['order_after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['match'] = $request['match']; $args['search'] = $request['search']; $args['searchby'] = $request['searchby']; $args['name_includes'] = $request['name_includes']; $args['name_excludes'] = $request['name_excludes']; $args['username_includes'] = $request['username_includes']; $args['username_excludes'] = $request['username_excludes']; $args['email_includes'] = $request['email_includes']; $args['email_excludes'] = $request['email_excludes']; $args['country_includes'] = $request['country_includes']; $args['country_excludes'] = $request['country_excludes']; $args['last_active_before'] = $request['last_active_before']; $args['last_active_after'] = $request['last_active_after']; $args['orders_count_min'] = $request['orders_count_min']; $args['orders_count_max'] = $request['orders_count_max']; $args['total_spend_min'] = $request['total_spend_min']; $args['total_spend_max'] = $request['total_spend_max']; $args['avg_order_value_min'] = $request['avg_order_value_min']; $args['avg_order_value_max'] = $request['avg_order_value_max']; $args['last_order_before'] = $request['last_order_before']; $args['last_order_after'] = $request['last_order_after']; $args['customers'] = $request['customers']; $args['users'] = $request['users']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $args['filter_empty'] = $request['filter_empty']; $args['user_type'] = $request['user_type']; $args['location_includes'] = $request['location_includes']; $args['location_excludes'] = $request['location_excludes']; $between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' ); $normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false ); $between_params_date = array( 'last_active', 'registered' ); $normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true ); $args = array_merge( $args, $normalized_params_numeric, $normalized_params_date ); return $args; } /** * Get one report. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_item( $request ) { $query_args = $this->prepare_reports_query( $request ); $query_args['customers'] = array( $request->get_param( 'id' ) ); $customers_query = new Query( $query_args ); $report_data = $customers_query->get_data(); $data = array(); foreach ( $report_data->data as $customer_data ) { $item = $this->prepare_item_for_response( $customer_data, $request ); $data[] = $this->prepare_response_for_collection( $item ); } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', (int) $report_data->total ); $response->header( 'X-WP-TotalPages', (int) $report_data->pages ); return $response; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $report, $request ); // Trim name field to prevent whitespace issues. $data['name'] = trim( $data['name'] ); // Registered date is UTC. $data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] ); $data['date_registered'] = wc_rest_prepare_date_response( $data['date_registered'], false ); // Last active date is local time. $data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false ); $data['date_last_active'] = wc_rest_prepare_date_response( $data['date_last_active'] ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. * @since 4.0.0 */ return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request ); } /** * Prepare links for the request. * * @param array $object Object data. * @return array */ protected function prepare_links( $object ) { if ( empty( $object['user_id'] ) ) { return array(); } return array( 'customer' => array( 'href' => rest_url( sprintf( '/%s/customers/%d', $this->namespace, $object['id'] ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '/%s/customers', $this->namespace ) ), ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Customer ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'user_id' => array( 'description' => __( 'User ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'first_name' => array( 'description' => __( 'First name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'last_name' => array( 'description' => __( 'Last name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'email' => array( 'description' => __( 'Email address.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'username' => array( 'description' => __( 'Username.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'country' => array( 'description' => __( 'Country / Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'city' => array( 'description' => __( 'City.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'state' => array( 'description' => __( 'Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'postcode' => array( 'description' => __( 'Postal code.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_registered' => array( 'description' => __( 'Date registered.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_registered_gmt' => array( 'description' => __( 'Date registered GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_last_active' => array( 'description' => __( 'Date last active.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_last_active_gmt' => array( 'description' => __( 'Date last active GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Order count.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_spend' => array( 'description' => __( 'Total spend.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_order_value' => array( 'description' => __( 'Avg order value.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby']['default'] = 'date_registered'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'username', 'name', 'first_name', 'last_name', 'email', 'location', 'country', 'city', 'state', 'postcode', 'date_registered', 'date_last_active', 'orders_count', 'total_spend', 'avg_order_value', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['searchby'] = array( 'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.', 'type' => 'string', 'default' => 'name', 'enum' => array( 'name', 'username', 'email', 'all', ), ); $params['name_includes'] = array( 'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['name_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_includes'] = array( 'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_includes'] = array( 'description' => __( 'Limit response to objects including emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_excludes'] = array( 'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_includes'] = array( 'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_before'] = array( 'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_after'] = array( 'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['orders_count_min'] = array( 'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_max'] = array( 'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_between'] = array( 'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['total_spend_min'] = array( 'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_max'] = array( 'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_between'] = array( 'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['avg_order_value_min'] = array( 'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_max'] = array( 'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_between'] = array( 'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['last_order_before'] = array( 'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_order_after'] = array( 'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['customers'] = array( 'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['users'] = array( 'description' => __( 'Limit result to items with specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['filter_empty'] = array( 'description' => __( 'Filter out results where any of the passed fields are empty', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', 'enum' => array( 'email', 'name', 'country', 'city', 'state', 'postcode', ), ), ); $params['user_type'] = array( 'description' => __( 'Limit result to items with specified user type.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'validate_callback' => 'rest_validate_request_arg', 'enum' => array( 'all', 'registered', 'guest', ), ); $params['location_includes'] = array( 'description' => __( 'Includes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['location_excludes'] = array( 'description' => __( 'Excludes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'name' => __( 'Name', 'woocommerce' ), 'username' => __( 'Username', 'woocommerce' ), 'last_active' => __( 'Last Active', 'woocommerce' ), 'registered' => __( 'Sign Up', 'woocommerce' ), 'email' => __( 'Email', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'total_spend' => __( 'Total Spend', 'woocommerce' ), 'avg_order_value' => __( 'AOV', 'woocommerce' ), 'country' => __( 'Country / Region', 'woocommerce' ), 'city' => __( 'City', 'woocommerce' ), 'region' => __( 'Region', 'woocommerce' ), 'postcode' => __( 'Postal Code', 'woocommerce' ), ); /** * Filter to add or remove column names from the customers report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_customers_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'name' => $item['name'], 'username' => $item['username'], 'last_active' => $item['date_last_active'], 'registered' => $item['date_registered'], 'email' => $item['email'], 'orders_count' => $item['orders_count'], 'total_spend' => self::csv_number_format( $item['total_spend'] ), 'avg_order_value' => self::csv_number_format( $item['avg_order_value'] ), 'country' => $item['country'], 'city' => $item['city'], 'region' => $item['state'], 'postcode' => $item['postcode'], ); /** * Filter the column values of an item being exported. * * @param object $export_item Key value pair of Column ID => Row Value. * @param object $item Single report item/row. * @since 4.0.0 */ return apply_filters( 'woocommerce_report_customers_prepare_export_item', $export_item, $item ); } } Reports/Customers/Stats/Controller.php 0000777 00000040746 15252174340 0014145 0 ustar 00 <?php /** * REST API Reports customers stats controller * * Handles requests to the /reports/customers/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; use Automattic\WooCommerce\Admin\API\Reports\Customers\Query; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * REST API Reports customers stats controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/customers/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['registered_before'] = $request['registered_before']; $args['registered_after'] = $request['registered_after']; $args['match'] = $request['match']; $args['search'] = $request['search']; $args['name_includes'] = $request['name_includes']; $args['name_excludes'] = $request['name_excludes']; $args['username_includes'] = $request['username_includes']; $args['username_excludes'] = $request['username_excludes']; $args['email_includes'] = $request['email_includes']; $args['email_excludes'] = $request['email_excludes']; $args['country_includes'] = $request['country_includes']; $args['country_excludes'] = $request['country_excludes']; $args['last_active_before'] = $request['last_active_before']; $args['last_active_after'] = $request['last_active_after']; $args['orders_count_min'] = $request['orders_count_min']; $args['orders_count_max'] = $request['orders_count_max']; $args['total_spend_min'] = $request['total_spend_min']; $args['total_spend_max'] = $request['total_spend_max']; $args['avg_order_value_min'] = $request['avg_order_value_min']; $args['avg_order_value_max'] = $request['avg_order_value_max']; $args['last_order_before'] = $request['last_order_before']; $args['last_order_after'] = $request['last_order_after']; $args['customers'] = $request['customers']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' ); $normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false ); $between_params_date = array( 'last_active', 'registered' ); $normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true ); $args = array_merge( $args, $normalized_params_numeric, $normalized_params_date ); return $args; } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); $customers_query = new Query( $query_args, 'customers-stats' ); $report_data = $customers_query->get_data(); $out_data = array( 'totals' => $report_data, ); return rest_ensure_response( $out_data ); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = $report; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_customers_stats', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { // @todo Should any of these be 'indicator's? $totals = array( 'customers_count' => array( 'description' => __( 'Number of customers.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_orders_count' => array( 'description' => __( 'Average number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_total_spend' => array( 'description' => __( 'Average total spend per customer.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'avg_avg_order_value' => array( 'description' => __( 'Average AOV per customer.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), ); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['searchby'] = array( 'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.', 'type' => 'string', 'default' => 'name', 'enum' => array( 'name', 'username', 'email', 'all', ), ); $params['name_includes'] = array( 'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['name_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_includes'] = array( 'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_includes'] = array( 'description' => __( 'Limit response to objects including emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_excludes'] = array( 'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_includes'] = array( 'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_before'] = array( 'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_after'] = array( 'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['orders_count_min'] = array( 'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_max'] = array( 'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_between'] = array( 'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['total_spend_min'] = array( 'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_max'] = array( 'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_between'] = array( 'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['avg_order_value_min'] = array( 'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_max'] = array( 'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_between'] = array( 'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['last_order_before'] = array( 'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_order_after'] = array( 'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['customers'] = array( 'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['fields'] = array( 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['force_cache_refresh'] = array( 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wp_validate_boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Customers/Stats/DataStore.php 0000777 00000007635 15252174340 0013710 0 ustar 00 <?php /** * API\Reports\Customers\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; /** * API\Reports\Customers\Stats\DataStore. */ class DataStore extends CustomersDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * * @override CustomersDataStore::$column_types * * @var array */ protected $column_types = array( 'customers_count' => 'intval', 'avg_orders_count' => 'floatval', 'avg_total_spend' => 'floatval', 'avg_avg_order_value' => 'floatval', ); /** * Cache identifier. * * @override CustomersDataStore::$cache_key * * @var string */ protected $cache_key = 'customers_stats'; /** * Data store context used to pass to filters. * * @override CustomersDataStore::$context * * @var string */ protected $context = 'customers_stats'; /** * Assign report columns once full table name has been assigned. * * @override CustomersDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'customers_count' => 'COUNT( * ) as customers_count', 'avg_orders_count' => 'AVG( orders_count ) as avg_orders_count', 'avg_total_spend' => 'AVG( total_spend ) as avg_total_spend', 'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value', ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override CustomersDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = ReportsDataStore::get_default_query_vars(); $defaults['orderby'] = 'date_registered'; // Do not set `order_before` and `order_after` here, like in the parent class. return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override CustomersDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'customers_count' => 0, 'avg_orders_count' => 0, 'avg_total_spend' => 0.0, 'avg_avg_order_value' => 0.0, ); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); // Clear SQL clauses set for parent class queries that are different here. $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' ); $this->subquery->add_sql_clause( 'select', 'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,' ); $this->subquery->add_sql_clause( 'select', 'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value' ); $this->clear_sql_clause( array( 'order_by', 'limit' ) ); $this->add_sql_clause( 'select', $selections ); $this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" ); $report_data = $wpdb->get_results( $this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $report_data ) { return $data; } $data = (object) $this->cast_numbers( $report_data[0] ); return $data; } } Reports/Customers/Stats/Query.php 0000777 00000004654 15252174340 0013125 0 ustar 00 <?php /** * Class for parameter-based Customers Report Stats querying * * Example usage: * $args = array( * 'registered_before' => '2018-07-19 00:00:00', * 'registered_after' => '2018-07-05 00:00:00', * 'page' => 2, * 'avg_order_value_min' => 100, * 'country' => 'GB', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Customers\Stats\Query * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Customers report. * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date_registered', 'fields' => '*', // @todo Needed? ); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'x.x.x', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_customers_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-customers-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_customers_stats_select_query', $results, $args ); } } Reports/Import/Controller.php 0000777 00000021110 15252174340 0012315 0 ustar 00 <?php /** * REST API Reports Import Controller * * Handles requests to /reports/import */ namespace Automattic\WooCommerce\Admin\API\Reports\Import; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\ReportsSync; /** * Reports Imports controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller */ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { /** * Route base. * * @var string */ protected $rest_base = 'reports/import'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'import_items' ), 'permission_callback' => array( $this, 'import_permissions_check' ), 'args' => $this->get_import_collection_params(), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/cancel', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'cancel_import' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'delete_imported_items' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_import_status' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/totals', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_import_totals' ), 'permission_callback' => array( $this, 'import_permissions_check' ), 'args' => $this->get_import_collection_params(), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); } /** * Makes sure the current user has access to WRITE the settings APIs. * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|bool */ public function import_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Import data based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function import_items( $request ) { $query_args = $this->prepare_objects_query( $request ); $import = ReportsSync::regenerate_report_data( $query_args['days'], $query_args['skip_existing'] ); if ( is_wp_error( $import ) ) { $result = array( 'status' => 'error', 'message' => $import->get_error_message(), ); } else { $result = array( 'status' => 'success', 'message' => $import, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Prepare request object as query args. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = array(); $args['skip_existing'] = $request['skip_existing']; $args['days'] = $request['days']; return $args; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_reports_import', $response, $item, $request ); } /** * Get the query params for collections. * * @return array */ public function get_import_collection_params() { $params = array(); $params['days'] = array( 'description' => __( 'Number of days to import.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 0, ); $params['skip_existing'] = array( 'description' => __( 'Skip importing existing order data.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_import_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_import', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Regeneration status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Regenerate data message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Cancel all queued import actions. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function cancel_import( $request ) { ReportsSync::clear_queued_actions(); $result = array( 'status' => 'success', 'message' => __( 'All pending and in-progress import actions have been cancelled.', 'woocommerce' ), ); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Delete all imported items. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function delete_imported_items( $request ) { $delete = ReportsSync::delete_report_data(); if ( is_wp_error( $delete ) ) { $result = array( 'status' => 'error', 'message' => $delete->get_error_message(), ); } else { $result = array( 'status' => 'success', 'message' => $delete, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Get the status of the current import. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_import_status( $request ) { $result = ReportsSync::get_import_stats(); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Get the total orders and customers based on user supplied params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_import_totals( $request ) { $query_args = $this->prepare_objects_query( $request ); $totals = ReportsSync::get_import_totals( $query_args['days'], $query_args['skip_existing'] ); $response = $this->prepare_item_for_response( $totals, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } } Reports/Export/Controller.php 0000777 00000015431 15252174340 0012335 0 ustar 00 <?php /** * REST API Reports Export Controller * * Handles requests to: * - /reports/[report]/export * - /reports/[report]/export/[id]/status */ namespace Automattic\WooCommerce\Admin\API\Reports\Export; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\ReportExporter; /** * Reports Export controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller */ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { /** * Route base. * * @var string */ protected $rest_base = 'reports/(?P<type>[a-z]+)/export'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'export_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_export_collection_params(), ), 'schema' => array( $this, 'get_export_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<export_id>[a-z0-9]+)/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'export_status' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_export_status_public_schema' ), ) ); } /** * Get the query params for collections. * * @return array */ protected function get_export_collection_params() { $params = array(); $params['report_args'] = array( 'description' => __( 'Parameters to pass on to the exported report.', 'woocommerce' ), 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', // @todo: use each controller's schema? ); $params['email'] = array( 'description' => __( 'When true, email a link to download the export to the requesting user.', 'woocommerce' ), 'type' => 'boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Report Export's schema, conforming to JSON Schema. * * @return array */ public function get_export_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_export', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Export status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Export status message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'export_id' => array( 'description' => __( 'Export ID.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the Export status schema, conforming to JSON Schema. * * @return array */ public function get_export_status_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_export_status', 'type' => 'object', 'properties' => array( 'percent_complete' => array( 'description' => __( 'Percentage complete.', 'woocommerce' ), 'type' => 'int', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'download_url' => array( 'description' => __( 'Export download URL.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Export data based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function export_items( $request ) { $report_type = $request['type']; $report_args = empty( $request['report_args'] ) ? array() : $request['report_args']; $send_email = isset( $request['email'] ) ? $request['email'] : false; $default_export_id = str_replace( '.', '', microtime( true ) ); $export_id = apply_filters( 'woocommerce_admin_export_id', $default_export_id ); $export_id = (string) sanitize_file_name( $export_id ); $total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email ); if ( 0 === $total_rows ) { return rest_ensure_response( array( 'message' => __( 'There is no data to export for the given request.', 'woocommerce' ), ) ); } ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 ); $response = rest_ensure_response( array( 'message' => __( 'Your report file is being generated.', 'woocommerce' ), 'export_id' => $export_id, ) ); // Include a link to the export status endpoint. $response->add_links( array( 'status' => array( 'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ), ), ) ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Export status based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function export_status( $request ) { $report_type = $request['type']; $export_id = $request['export_id']; $percentage = ReportExporter::get_export_percentage_complete( $report_type, $export_id ); if ( false === $percentage ) { return new \WP_Error( 'woocommerce_admin_reports_export_invalid_id', __( 'Sorry, there is no export with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $result = array( 'percent_complete' => $percentage, ); // @todo - add thing in the links below instead? if ( 100 === $percentage ) { $query_args = array( 'action' => ReportExporter::DOWNLOAD_EXPORT_ACTION, 'filename' => "wc-{$report_type}-report-export-{$export_id}", ); $result['download_url'] = add_query_arg( $query_args, admin_url() ); } // Wrap the data in a response object. $response = rest_ensure_response( $result ); // Include a link to the export status endpoint. $response->add_links( array( 'self' => array( 'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ), ), ) ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } } Reports/Revenue/Stats/Controller.php 0000777 00000022224 15252174340 0013561 0 ustar 00 <?php /** * REST API Reports revenue stats controller * * Handles requests to the /reports/revenue/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports revenue stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/revenue/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $args['date_type'] = $request['date_type']; return $args; } /** * Get data from RevenueQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new RevenueQuery( $query_args ); return $query->get_data(); } /** * Get report items for export. * * Returns only the interval data. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response */ public function get_export_items( $request ) { $response = $this->get_items( $request ); $data = $response->get_data(); $intervals = $data['intervals']; $response->set_data( $intervals ); return $response; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_revenue_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'total_sales' => array( 'description' => __( 'Total sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'coupons' => array( 'description' => __( 'Amount discounted by coupons.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons_count' => array( 'description' => __( 'Unique coupons count.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'shipping' => array( 'title' => __( 'Shipping', 'woocommerce' ), 'description' => __( 'Total of shipping.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'taxes' => array( 'description' => __( 'Total of taxes.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'refunds' => array( 'title' => __( 'Returns', 'woocommerce' ), 'description' => __( 'Total of returns.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'gross_sales' => array( 'description' => __( 'Gross sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_revenue_stats'; // Products is not shown in intervals, only in totals. $schema['properties']['totals']['properties']['products'] = array( 'description' => __( 'Products sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'total_sales', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', 'gross_sales', ) ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', 'coupon', 'customer_type', // new vs returning. ), 'validate_callback' => 'rest_validate_request_arg', ); $params['date_type'] = array( 'description' => __( 'Override the "woocommerce_date_type" option that is used for the database date field considered for revenue reports.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'date_paid', 'date_created', 'date_completed', ), 'validate_callback' => 'rest_validate_request_arg', ); unset( $params['fields'] ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { return array( 'date' => __( 'Date', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'gross_sales' => __( 'Gross sales', 'woocommerce' ), 'refunds' => __( 'Returns', 'woocommerce' ), 'coupons' => __( 'Coupons', 'woocommerce' ), 'net_revenue' => __( 'Net sales', 'woocommerce' ), 'taxes' => __( 'Taxes', 'woocommerce' ), 'shipping' => __( 'Shipping', 'woocommerce' ), 'total_sales' => __( 'Total sales', 'woocommerce' ), ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $subtotals = (array) $item['subtotals']; return array( 'date' => $item['date_start'], 'orders_count' => $subtotals['orders_count'], 'gross_sales' => self::csv_number_format( $subtotals['gross_sales'] ), 'refunds' => self::csv_number_format( $subtotals['refunds'] ), 'coupons' => self::csv_number_format( $subtotals['coupons'] ), 'net_revenue' => self::csv_number_format( $subtotals['net_revenue'] ), 'taxes' => self::csv_number_format( $subtotals['taxes'] ), 'shipping' => self::csv_number_format( $subtotals['shipping'] ), 'total_sales' => self::csv_number_format( $subtotals['total_sales'] ), ); } } Reports/Revenue/Query.php 0000777 00000003277 15252174340 0011454 0 ustar 00 <?php /** * Class for parameter-based Revenue Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Revenue\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Revenue; defined( 'ABSPATH' ) || exit; /** * API\Reports\Revenue\Query * * This query uses inconsistent names: * - `report-revenue-stats` data store * - `woocommerce_analytics_revenue_*` filters * So, for backward compatibility, we cannot use GenericQuery. */ class Query extends \WC_Object_Query { /** * Valid fields for Revenue report. * * @return array */ protected function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date', 'before' => '', 'after' => '', 'interval' => 'week', 'fields' => array( 'orders_count', 'num_items_sold', 'total_sales', 'coupons', 'coupons_count', 'refunds', 'taxes', 'shipping', 'net_revenue', 'gross_sales', ), ); } /** * Get revenue data based on the current query vars. * * @return array */ public function get_data() { $args = apply_filters( 'woocommerce_analytics_revenue_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-revenue-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_revenue_select_query', $results, $args ); } } Reports/Orders/Controller.php 0000777 00000043132 15252174340 0012311 0 ustar 00 <?php /** * REST API Reports orders controller * * Handles requests to the /reports/orders endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports orders controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/orders'; /** * Get data from Orders\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['variation_includes'] = (array) $request['variation_includes']; $args['variation_excludes'] = (array) $request['variation_excludes']; $args['coupon_includes'] = (array) $request['coupon_includes']; $args['coupon_excludes'] = (array) $request['coupon_excludes']; $args['tax_rate_includes'] = (array) $request['tax_rate_includes']; $args['tax_rate_excludes'] = (array) $request['tax_rate_excludes']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['customer_type'] = $request['customer_type']; $args['extended_info'] = $request['extended_info']; $args['refunds'] = $request['refunds']; $args['match'] = $request['match']; $args['order_includes'] = $request['order_includes']; $args['order_excludes'] = $request['order_excludes']; $args['attribute_is'] = (array) $request['attribute_is']; $args['attribute_is_not'] = (array) $request['attribute_is_not']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $report['order_number'] = $this->get_order_number( $report['order_id'] ); $report['total_formatted'] = $this->get_total_formatted( $report['order_id'] ); // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_orders', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'order' => array( 'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $object['order_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_orders', 'type' => 'object', 'properties' => array( 'order_id' => array( 'description' => __( 'Order ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'order_number' => array( 'description' => __( 'Order Number.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created' => array( 'description' => __( "Date the order was created, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created_gmt' => array( 'description' => __( 'Date the order was created, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Order status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'customer_id' => array( 'description' => __( 'Customer ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Number of items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'net_total' => array( 'description' => __( 'Net total revenue.', 'woocommerce' ), 'type' => 'float', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_formatted' => array( 'description' => __( 'Net total revenue (formatted).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'customer_type' => array( 'description' => __( 'Returning or new customer.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'products' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'List of order product IDs, names, quantities.', 'woocommerce' ), ), 'coupons' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'List of order coupons.', 'woocommerce' ), ), 'customer' => array( 'type' => 'object', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order customer information.', 'woocommerce' ), ), 'attribution' => array( 'type' => 'object', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order attribution information.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['per_page']['minimum'] = 0; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'num_items_sold', 'net_total', ) ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variation_includes'] = array( 'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['variation_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_includes'] = array( 'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['coupon_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['tax_rate_includes'] = array( 'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['tax_rate_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['customer_type'] = array( 'description' => __( 'Limit result set to returning or new customers.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'returning', 'new', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['refunds'] = array( 'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'all', 'partial', 'full', 'none', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get customer name column export value. * * @param array $customer Customer from report row. * @return string */ protected function get_customer_name( $customer ) { return $customer['first_name'] . ' ' . $customer['last_name']; } /** * Get products column export value. * * @param array $products Products from report row. * @return string */ protected function get_products( $products ) { $products_list = array(); foreach ( $products as $product ) { $products_list[] = sprintf( /* translators: 1: numeric product quantity, 2: name of product */ __( '%1$s× %2$s', 'woocommerce' ), $product['quantity'], $product['name'] ); } return implode( ', ', $products_list ); } /** * Get coupons column export value. * * @param array $coupons Coupons from report row. * @return string */ protected function get_coupons( $coupons ) { return implode( ', ', wp_list_pluck( $coupons, 'code' ) ); } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'date_created' => __( 'Date', 'woocommerce' ), 'order_number' => __( 'Order #', 'woocommerce' ), 'total_formatted' => __( 'N. Revenue (formatted)', 'woocommerce' ), 'status' => __( 'Status', 'woocommerce' ), 'customer_name' => __( 'Customer', 'woocommerce' ), 'customer_type' => __( 'Customer type', 'woocommerce' ), 'products' => __( 'Product(s)', 'woocommerce' ), 'num_items_sold' => __( 'Items sold', 'woocommerce' ), 'coupons' => __( 'Coupon(s)', 'woocommerce' ), 'net_total' => __( 'Net Sales', 'woocommerce' ), 'attribution' => __( 'Attribution', 'woocommerce' ), ); /** * Filter to add or remove column names from the orders report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_orders_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'date_created' => $item['date'], 'order_number' => $item['order_number'], 'total_formatted' => $item['total_formatted'], 'status' => $item['status'], 'customer_name' => isset( $item['extended_info']['customer'] ) ? $this->get_customer_name( $item['extended_info']['customer'] ) : null, 'customer_type' => $item['customer_type'], 'products' => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null, 'num_items_sold' => $item['num_items_sold'], 'coupons' => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null, 'net_total' => $item['net_total'], 'attribution' => $item['extended_info']['attribution']['origin'], ); /** * Filter to prepare extra columns in the export item for the orders * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_orders_prepare_export_item', $export_item, $item ); } } Reports/Orders/Stats/DataStore.php 0000777 00000072546 15252174340 0013165 0 ustar 00 <?php /** * API\Reports\Orders\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Utilities\OrderUtil; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; use Automattic\WooCommerce\Utilities\FeaturesUtil; use WC_Order; /** * API\Reports\Orders\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Option name to store whether the wc_order_stats table has a column `fulfillment_status` * * @var string */ const OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS = 'woocommerce_order_stats_has_fulfillment_column'; /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_stats'; /** * Cron event name. */ const CRON_EVENT = 'wc_order_stats_update'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'orders_stats'; /** * Type for each column to cast values correctly later. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'orders_count' => 'intval', 'num_items_sold' => 'intval', 'gross_sales' => 'floatval', 'total_sales' => 'floatval', 'coupons' => 'floatval', 'coupons_count' => 'intval', 'refunds' => 'floatval', 'taxes' => 'floatval', 'shipping' => 'floatval', 'net_revenue' => 'floatval', 'avg_items_per_order' => 'floatval', 'avg_order_value' => 'floatval', 'total_customers' => 'intval', 'products' => 'intval', 'segment_id' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'orders_stats'; /** * Dynamically sets the date column name based on configuration * * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); parent::__construct(); } /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); // Avoid ambiguous columns in SQL query. $refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total + {$table_name}.tax_total + {$table_name}.shipping_total ELSE 0 END ) )"; if ( ! OrderUtil::uses_new_full_refund_data() ) { $refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )"; } $gross_sale_sum = "{$table_name}.total_sales - {$table_name}.tax_total - {$table_name}.shipping_total"; $gross_sales = "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$gross_sale_sum} ELSE 0 END ) + COALESCE( SUM(discount_amount), 0 ) as gross_sales"; $this->report_columns = array( 'orders_count' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) as orders_count", 'num_items_sold' => "SUM({$table_name}.num_items_sold) as num_items_sold", 'gross_sales' => $gross_sales, 'total_sales' => "SUM({$table_name}.total_sales) AS total_sales", 'coupons' => 'COALESCE( SUM(discount_amount), 0 ) AS coupons', // SUM() all nulls gives null. 'coupons_count' => 'COALESCE( coupons_count, 0 ) as coupons_count', 'refunds' => "{$refunds} AS refunds", 'taxes' => "SUM({$table_name}.tax_total) AS taxes", 'shipping' => "SUM({$table_name}.shipping_total) AS shipping", 'net_revenue' => "SUM({$table_name}.net_total) AS net_revenue", 'avg_items_per_order' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$table_name}.num_items_sold ELSE 0 END ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_items_per_order", 'avg_order_value' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$table_name}.net_total ELSE 0 END ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( {$table_name}.customer_id ) ) as total_customers", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_before_delete_order', array( __CLASS__, 'delete_order' ) ); add_action( 'delete_post', array( __CLASS__, 'delete_order' ) ); } /** * Updates the totals and intervals database queries with parameters used for Orders report: categories, coupons and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function orders_stats_sql_filter( $query_args ) { // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo Performance of all of this? global $wpdb; $from_clause = ''; $orders_stats_table = self::get_db_table_name(); $product_lookup = $wpdb->prefix . 'wc_order_product_lookup'; $coupon_lookup = $wpdb->prefix . 'wc_order_coupon_lookup'; $tax_rate_lookup = $wpdb->prefix . 'wc_order_tax_lookup'; $operator = $this->get_match_operator( $query_args ); $where_filters = array(); // Products filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'product_id', 'IN', $this->get_included_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'product_id', 'NOT IN', $this->get_excluded_products( $query_args ) ); // Variations filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'variation_id', 'IN', $this->get_included_variations( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'variation_id', 'NOT IN', $this->get_excluded_variations( $query_args ) ); // Coupons filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $coupon_lookup, 'coupon_id', 'IN', $this->get_included_coupons( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $coupon_lookup, 'coupon_id', 'NOT IN', $this->get_excluded_coupons( $query_args ) ); // Tax rate filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $tax_rate_lookup, 'tax_rate_id', 'IN', implode( ',', $query_args['tax_rate_includes'] ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $tax_rate_lookup, 'tax_rate_id', 'NOT IN', implode( ',', $query_args['tax_rate_excludes'] ) ); // Product attribute filters. $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { // Build a subquery for getting order IDs by product attribute(s). // Done here since our use case is a little more complicated than get_object_where_filter() can handle. $attribute_subquery = new SqlQuery(); $attribute_subquery->add_sql_clause( 'select', "{$orders_stats_table}.order_id" ); $attribute_subquery->add_sql_clause( 'from', $orders_stats_table ); // JOIN on product lookup. $attribute_subquery->add_sql_clause( 'join', "JOIN {$product_lookup} ON {$orders_stats_table}.order_id = {$product_lookup}.order_id" ); // Add JOINs for matching attributes. foreach ( $attribute_subqueries['join'] as $attribute_join ) { $attribute_subquery->add_sql_clause( 'join', $attribute_join ); } // Add WHEREs for matching attributes. $attribute_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' ); // Generate subquery statement and add to our where filters. $where_filters[] = "{$orders_stats_table}.order_id IN (" . $attribute_subquery->get_query_statement() . ')'; } $where_filters[] = $this->get_customer_subquery( $query_args ); $refund_subquery = $this->get_refund_subquery( $query_args ); $from_clause .= $refund_subquery['from_clause']; if ( $refund_subquery['where_clause'] ) { $where_filters[] = $refund_subquery['where_clause']; } $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); // Append status filter after to avoid matching ANY on default statuses. $order_status_filter = $this->get_status_subquery( $query_args, $operator ); if ( $order_status_filter ) { if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) { $operator = 'AND'; } $where_subclause = implode( " $operator ", array_filter( array( $where_subclause, $order_status_filter ) ) ); } // To avoid requesting the subqueries twice, the result is applied to all queries passed to the method. if ( $where_subclause ) { $this->total_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); $this->total_query->add_sql_clause( 'join', $from_clause ); $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); $this->interval_query->add_sql_clause( 'join', $from_clause ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = array_merge( parent::get_default_query_vars(), array( 'interval' => 'week', 'segmentby' => '', 'match' => 'all', 'status_is' => array(), 'status_is_not' => array(), 'product_includes' => array(), 'product_excludes' => array(), 'coupon_includes' => array(), 'coupon_excludes' => array(), 'tax_rate_includes' => array(), 'tax_rate_excludes' => array(), 'customer_type' => '', 'category_includes' => array(), ) ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); if ( isset( $query_args['date_type'] ) ) { $this->date_column_name = $query_args['date_type']; } $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->add_time_period_sql_params( $query_args, $table_name ); $this->add_intervals_sql_params( $query_args, $table_name ); $this->add_order_by_sql_params( $query_args ); $where_time = $this->get_sql_clause( 'where_time' ); $params = $this->get_limit_sql_params( $query_args ); $coupon_join = "LEFT JOIN ( SELECT order_id, SUM(discount_amount) AS discount_amount, COUNT(DISTINCT coupon_id) AS coupons_count FROM {$wpdb->prefix}wc_order_coupon_lookup GROUP BY order_id ) order_coupon_lookup ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id"; // Additional filtering for Orders report. $this->orders_stats_sql_filter( $query_args ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'left_join', $coupon_join ); $this->total_query->add_sql_clause( 'where_time', $where_time ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo Remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $where_time, 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $where_time, 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); $totals[0]['products'] = $unique_products; $segmenter = new Segmenter( $query_args, $this->report_columns ); $unique_coupons = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); $totals[0]['coupons_count'] = $unique_coupons; $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->add_sql_clause( 'left_join', $coupon_join ); $this->interval_query->add_sql_clause( 'where_time', $where_time ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.{$this->date_column_name}) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } if ( isset( $intervals[0] ) ) { $unique_coupons = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true ); $intervals[0]['coupons_count'] = $unique_coupons; } $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Get unique products based on user time query * * @param string $from_clause From clause with date query. * @param string $where_time_clause Where clause with date query. * @param string $where_clause Where clause with date query. * @return integer Unique product count. */ public function get_unique_product_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT( DISTINCT {$wpdb->prefix}wc_order_product_lookup.product_id ) FROM {$wpdb->prefix}wc_order_product_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_product_lookup.order_id = {$table_name}.order_id {$from_clause} WHERE 1=1 {$where_time_clause} {$where_clause}" ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. } /** * Get unique coupons based on user time query * * @param string $from_clause From clause with date query. * @param string $where_time_clause Where clause with date query. * @param string $where_clause Where clause with date query. * @return integer Unique product count. */ public function get_unique_coupon_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT(DISTINCT coupon_id) FROM {$wpdb->prefix}wc_order_coupon_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_coupon_lookup.order_id = {$table_name}.order_id {$from_clause} WHERE 1=1 {$where_time_clause} {$where_clause}" ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. } /** * Add order information to the lookup table when orders are created or modified. * * @param int $post_id Post ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order( $post_id ) { if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return -1; } $order = wc_get_order( $post_id ); if ( ! $order ) { return -1; } return self::update( $order ); } /** * Update the database with stats data. * * @param WC_Order|WC_Order_Refund $order Order or refund to update row for. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function update( $order ) { global $wpdb; $table_name = self::get_db_table_name(); if ( ! $order->get_id() || ! $order->get_date_created() ) { return -1; } $format = array( '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%f', '%f', '%f', '%f', '%s', '%d', '%d', ); $data = array( 'order_id' => $order->get_id(), 'parent_id' => $order->get_parent_id(), 'date_created' => $order->get_date_created()->date( 'Y-m-d H:i:s' ), 'date_paid' => $order->get_date_paid() ? $order->get_date_paid()->date( 'Y-m-d H:i:s' ) : null, 'date_completed' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d H:i:s' ) : null, 'date_created_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ), 'num_items_sold' => self::get_num_items_sold( $order ), 'total_sales' => $order->get_total(), 'tax_total' => $order->get_total_tax(), 'shipping_total' => $order->get_shipping_total(), 'net_total' => self::get_net_total( $order ), 'status' => self::normalize_order_status( $order->get_status() ), 'customer_id' => $order->get_report_customer_id(), 'returning_customer' => $order->is_returning_customer(), ); $order_fulfillment_status = ''; if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) && true === self::has_fulfillment_status_column() && $order instanceof WC_Order ) { $order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order ); $data['fulfillment_status'] = ( 'no_fulfillments' !== $order_fulfillment_status ) ? $order_fulfillment_status : null; $format[] = '%s'; } /** * Filters order stats data. * * @param array $data Data written to order stats lookup table. * @param WC_Order $order Order object. * * @since 4.0.0 */ $data = apply_filters( 'woocommerce_analytics_update_order_stats_data', $data, $order ); if ( 'shop_order_refund' === $order->get_type() ) { $parent_order = wc_get_order( $order->get_parent_id() ); if ( $parent_order ) { $data['parent_id'] = $parent_order->get_id(); $data['status'] = self::normalize_order_status( $parent_order->get_status() ); $refund_type = $order->get_meta( '_refund_type' ); $uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data(); if ( 'full' === $refund_type && $uses_new_full_refund_data ) { $data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order ); $data['tax_total'] = -1 * $parent_order->get_total_tax(); $data['net_total'] = -1 * self::get_net_total( $parent_order ); $data['shipping_total'] = -1 * $parent_order->get_shipping_total(); } } /** * Set date_completed and date_paid the same as date_created to avoid problems * when they are being used to sort the data, as refunds don't have them filled */ $data['date_completed'] = $data['date_created']; $data['date_paid'] = $data['date_created']; } // Update or add the information to the DB. $result = $wpdb->replace( $table_name, $data, $format ); /** * Fires when order's stats reports are updated. * * @param int $order_id Order ID. * * @since 4.0.0. */ do_action( 'woocommerce_analytics_update_order_stats', $order->get_id() ); // Check the rows affected for success. Using REPLACE can affect 2 rows if the row already exists. return ( 1 === $result || 2 === $result ); } /** * Deletes the order stats when an order is deleted. * * @param int $post_id Post ID. */ public static function delete_order( $post_id ) { global $wpdb; $order_id = (int) $post_id; if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return; } // Retrieve customer details before the order is deleted. $order = wc_get_order( $order_id ); $customer_id = absint( CustomersDataStore::get_existing_customer_id_from_order( $order ) ); // Delete the order. $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when orders stats are deleted. * * @param int $order_id Order ID. * @param int $customer_id Customer ID. * * @since 4.0.0 */ do_action( 'woocommerce_analytics_delete_order_stats', $order_id, $customer_id ); ReportsCache::invalidate(); } /** * Calculation methods. */ /** * Get number of items sold among all orders. * * @param WC_Order $order WC_Order object. * @return int */ protected static function get_num_items_sold( $order ) { $num_items = 0; $line_items = $order->get_items( 'line_item' ); foreach ( $line_items as $line_item ) { $num_items += $line_item->get_quantity(); } return $num_items; } /** * Get the net amount from an order without shipping, tax, or refunds. * * @param WC_Order $order WC_Order object. * @return float */ protected static function get_net_total( $order ) { $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() ); return (float) $net_total; } /** * Check if the wc_order_stats table has the fulfillment_status column. * * @return boolean */ public static function has_fulfillment_status_column() { $column_status = get_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS ); if ( ! empty( $column_status ) ) { return 'yes' === $column_status; } global $wpdb; $table_name = self::get_db_table_name(); // Check if the table exists. $table_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. 'SHOW TABLES LIKE %s', $table_name ) ); // If table still does not exist, return false without setting the option to allow for table to be created with the column. if ( ! $table_exists ) { return false; } $column_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. "SHOW COLUMNS FROM `{$table_name}` LIKE %s", 'fulfillment_status' ) ); if ( ! empty( $column_exists ) ) { update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } // Update the option to indicate that the column does not exist. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'no', false ); return false; } /** * Check to see if an order's customer has made previous orders or not * * @param WC_Order $order WC_Order object. * @param int|false $customer_id Customer ID. Optional. * @return bool */ public static function is_returning_customer( $order, $customer_id = null ) { if ( is_null( $customer_id ) ) { $customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_existing_customer_id_from_order( $order ); } if ( ! $customer_id ) { return false; } $oldest_orders = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_oldest_orders( $customer_id ); if ( empty( $oldest_orders ) ) { return false; } $first_order = $oldest_orders[0]; $second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false; $excluded_statuses = self::get_excluded_report_order_statuses(); // Order is older than previous first order. if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) && ! in_array( $order->get_status(), $excluded_statuses, true ) ) { self::set_customer_first_order( $customer_id, $order->get_id() ); return false; } // The current order is the oldest known order. $is_first_order = (int) $order->get_id() === (int) $first_order->order_id; // Order date has changed and next oldest is now the first order. $date_change = $second_order && $order->get_date_created() > wc_string_to_datetime( $first_order->date_created ) && wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created(); // Status has changed to an excluded status and next oldest order is now the first order. $status_change = $second_order && in_array( $order->get_status(), $excluded_statuses, true ); if ( $is_first_order && ( $date_change || $status_change ) ) { self::set_customer_first_order( $customer_id, $second_order->order_id ); return true; } return (int) $order->get_id() !== (int) $first_order->order_id; } /** * Set a customer's first order and all others to returning. * * @param int $customer_id Customer ID. * @param int $order_id Order ID. */ protected static function set_customer_first_order( $customer_id, $order_id ) { global $wpdb; $orders_stats_table = self::get_db_table_name(); $wpdb->query( $wpdb->prepare( // phpcs:ignore Generic.Commenting.Todo.TaskFound // TODO: use the %i placeholder to prepare the table name when available in the minimum required WordPress version. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", $order_id, $customer_id ) ); } /** * Add fulfillment_status column to wc_order_stats table. * * @return bool|string True on success, error message string on failure. */ public static function add_fulfillment_status_column() { if ( self::has_fulfillment_status_column() ) { return true; } global $wpdb; $result = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching "ALTER TABLE {$wpdb->prefix}wc_order_stats ADD COLUMN fulfillment_status VARCHAR(50) DEFAULT NULL, ADD INDEX fulfillment_status (fulfillment_status)" ); if ( false === $result ) { return $wpdb->last_error ? $wpdb->last_error : __( 'Unknown database error occurred while adding fulfillment_status column.', 'woocommerce' ); } // Update the option to indicate that the column has been added. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } } Reports/Orders/Stats/Controller.php 0000777 00000032667 15252174340 0013422 0 ustar 00 <?php /** * REST API Reports orders stats controller * * Handles requests to the /reports/orders/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query; /** * REST API Reports orders stats controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericStatsController */ class Controller extends GenericStatsController { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/orders/stats'; /** * Get data from Orders\Stats\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['fields'] = $request['fields']; $args['match'] = $request['match']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['variation_includes'] = (array) $request['variation_includes']; $args['variation_excludes'] = (array) $request['variation_excludes']; $args['coupon_includes'] = (array) $request['coupon_includes']; $args['coupon_excludes'] = (array) $request['coupon_excludes']; $args['tax_rate_includes'] = (array) $request['tax_rate_includes']; $args['tax_rate_excludes'] = (array) $request['tax_rate_excludes']; $args['customer_type'] = $request['customer_type']; $args['refunds'] = $request['refunds']; $args['attribute_is'] = (array) $request['attribute_is']; $args['attribute_is_not'] = (array) $request['attribute_is_not']; $args['category_includes'] = (array) $request['categories']; $args['segmentby'] = $request['segmentby']; $args['force_cache_refresh'] = $request['force_cache_refresh']; // For backwards compatibility, `customer` is aliased to `customer_type`. if ( empty( $request['customer_type'] ) && ! empty( $request['customer'] ) ) { $args['customer_type'] = $request['customer']; } return $args; } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_orders_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'title' => __( 'Orders', 'woocommerce' ), 'description' => __( 'Number of orders', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'avg_order_value' => array( 'description' => __( 'Average order value.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'avg_items_per_order' => array( 'description' => __( 'Average items per order', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Number of items sold', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons' => array( 'description' => __( 'Amount discounted by coupons.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons_count' => array( 'description' => __( 'Unique coupons count.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_customers' => array( 'description' => __( 'Total distinct customers.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'products' => array( 'description' => __( 'Number of distinct products sold.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_orders_stats'; // Products is not shown in intervals. unset( $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['products'] ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'avg_order_value', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'default' => null, 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); // Split assignments for PHPCS complaining on aligned. $params['variation_includes'] = array( 'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['variation_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_includes'] = array( 'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['tax_rate_includes'] = array( 'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['tax_rate_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['customer'] = array( 'description' => __( 'Alias for customer_type (deprecated).', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'new', 'returning', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['customer_type'] = array( 'description' => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'new', 'returning', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['refunds'] = array( 'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'all', 'partial', 'full', 'none', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', 'coupon', 'customer_type', // new vs returning. ), 'validate_callback' => 'rest_validate_request_arg', ); unset( $params['intervals'] ); unset( $params['fields'] ); return $params; } } Reports/Orders/Stats/Segmenter.php 0000777 00000051722 15252174340 0013221 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'num_items_sold' => "SUM($products_table.product_qty) as num_items_sold", 'total_sales' => "SUM($products_table.product_gross_revenue) AS total_sales", 'coupons' => 'SUM( coupon_lookup_left_join.discount_amount ) AS coupons', 'coupons_count' => 'COUNT( DISTINCT( coupon_lookup_left_join.coupon_id ) ) AS coupons_count', 'refunds' => "SUM( CASE WHEN $products_table.product_gross_revenue < 0 THEN $products_table.product_gross_revenue ELSE 0 END ) AS refunds", 'taxes' => "SUM($products_table.tax_amount) AS taxes", 'shipping' => "SUM($products_table.shipping_amount) AS shipping", 'net_revenue' => "SUM($products_table.product_net_revenue) AS net_revenue", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-related product-level segmenting query * (e.g. avg items per order when segmented by category). * * @param string $unique_orders_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $unique_orders_table ) { $columns_mapping = array( 'orders_count' => "COUNT($unique_orders_table.order_id) AS orders_count", 'avg_items_per_order' => "AVG($unique_orders_table.num_items_sold) AS avg_items_per_order", 'avg_order_value' => "SUM($unique_orders_table.net_total) / COUNT($unique_orders_table.order_id) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( $unique_orders_table.customer_id ) ) AS total_customers", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-level segmenting query * (e.g. avg items per order or Net sales when segmented by coupons). * * @param string $order_stats_table Name of SQL table containing the order-level info. * @param array $overrides Array of overrides for default column calculations. * * @return array Column => SELECT query mapping. */ protected function segment_selections_orders( $order_stats_table, $overrides = array() ) { $columns_mapping = array( 'num_items_sold' => "SUM($order_stats_table.num_items_sold) as num_items_sold", 'total_sales' => "SUM($order_stats_table.total_sales) AS total_sales", 'coupons' => "SUM($order_stats_table.discount_amount) AS coupons", 'coupons_count' => 'COUNT( DISTINCT(coupon_lookup_left_join.coupon_id) ) AS coupons_count', 'refunds' => "SUM( CASE WHEN $order_stats_table.parent_id != 0 THEN $order_stats_table.total_sales END ) AS refunds", 'taxes' => "SUM($order_stats_table.tax_total) AS taxes", 'shipping' => "SUM($order_stats_table.shipping_total) AS shipping", 'net_revenue' => "SUM($order_stats_table.net_total) AS net_revenue", 'orders_count' => "COUNT($order_stats_table.order_id) AS orders_count", 'avg_items_per_order' => "AVG($order_stats_table.num_items_sold) AS avg_items_per_order", 'avg_order_value' => "SUM($order_stats_table.net_total) / COUNT($order_stats_table.order_id) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( $order_stats_table.customer_id ) ) AS total_customers", ); if ( $overrides ) { $columns_mapping = array_merge( $columns_mapping, $overrides ); } return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Order level numbers. // As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc. $segments_orders = $wpdb->get_results( "SELECT $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name {$segmenting_selections['order_level']} FROM ( SELECT $table_name.order_id, $segmenting_groupby AS $segmenting_dimension_name, MAX( num_items_sold ) AS num_items_sold, MAX( net_total ) as net_total, MAX( returning_customer ) AS returning_customer, MAX( $table_name.customer_id ) as customer_id FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $product_segmenting_table.order_id, $segmenting_groupby ) AS $unique_orders_table GROUP BY $unique_orders_table.$segmenting_dimension_name", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, $segments_orders ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments. $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Order level numbers. // As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc. $segments_orders = $wpdb->get_results( "SELECT $unique_orders_table.time_interval AS time_interval, $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name {$segmenting_selections['order_level']} FROM ( SELECT MAX( $table_name.date_created ) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $table_name.order_id, $segmenting_groupby AS $segmenting_dimension_name, MAX( num_items_sold ) AS num_items_sold, MAX( net_total ) as net_total, MAX( returning_customer ) AS returning_customer, MAX( $table_name.customer_id ) as customer_id FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $product_segmenting_table.order_id, $segmenting_groupby ) AS $unique_orders_table GROUP BY time_interval, $unique_orders_table.$segmenting_dimension_name $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, $segments_orders ); return $intervals_segments; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $segmenting_limit = ''; $limit_parts = explode( ',', $intervals_query['limit'] ); if ( 2 === count( $limit_parts ) ) { $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); } $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_from = "LEFT JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup_left_join ON ($table_name.order_id = coupon_lookup_left_join.order_id) "; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { // @todo How to handle shipping taxes when grouped by product? $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= " INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id) LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { // As there can be 2 or more coupons applied per one order, coupon amount needs to be split. $coupon_override = array( 'coupons' => 'SUM(coupon_lookup.discount_amount) AS coupons', ); $coupon_level_columns = $this->segment_selections_orders( $table_name, $coupon_override ); $segmenting_selections = $this->prepare_selections( $coupon_level_columns ); $this->report_columns = $coupon_level_columns; $segmenting_from .= " INNER JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup ON ($table_name.order_id = coupon_lookup.order_id) "; $segmenting_groupby = 'coupon_lookup.coupon_id'; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } elseif ( 'customer_type' === $this->query_args['segmentby'] ) { $customer_level_columns = $this->segment_selections_orders( $table_name ); $segmenting_selections = $this->prepare_selections( $customer_level_columns ); $this->report_columns = $customer_level_columns; $segmenting_groupby = "$table_name.returning_customer"; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } Reports/Orders/Stats/Query.php 0000777 00000002451 15252174340 0012370 0 ustar 00 <?php /** * Class for parameter-based Order Stats Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * 'categories' => array(15, 18), * 'coupons' => array(138), * 'status_in' => array('completed'), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Orders\Stats\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'orders-stats'; /** * Valid fields for Orders report. * * @return array */ protected function get_default_query_vars() { return array( 'fields' => array( 'net_revenue', 'avg_order_value', 'orders_count', 'avg_items_per_order', 'num_items_sold', 'coupons', 'coupons_count', 'total_customers', ), ); } } Reports/Orders/Query.php 0000777 00000002310 15252174340 0011264 0 ustar 00 <?php /** * Class for parameter-based Orders Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * 'products' => array(15, 18), * 'coupons' => array(138), * 'status_is' => array('completed'), * 'status_is_not' => array('failed'), * 'new_customers' => false, * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Orders\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'orders'; /** * Get the default allowed query vars. * * @return array */ protected function get_default_query_vars() { return \WC_Object_Query::get_default_query_vars(); } } Reports/Orders/DataStore.php 0000777 00000054743 15252174340 0012066 0 ustar 00 <?php /** * API\Reports\Orders\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta; use Automattic\WooCommerce\Utilities\OrderUtil; /** * API\Reports\Orders\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use OrderAttributionMeta; /** * The cache key for order statuses. */ const ORDERS_STATUSES_ALL_CACHE_KEY = 'woocommerce_analytics_orders_statuses_all'; /** * Dynamically sets the date column name based on configuration * * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); parent::__construct(); } /** * Set up all the hooks for maintaining data consistency (transients and co). * * @internal */ final public static function init() { add_action( 'woocommerce_analytics_update_order_stats', array( __CLASS__, 'maybe_update_order_statuses_cache' ) ); } /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_stats'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'orders'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'order_id' => 'intval', 'parent_id' => 'intval', 'date_created' => 'strval', 'date_created_gmt' => 'strval', 'status' => 'strval', 'customer_id' => 'intval', 'net_total' => 'floatval', 'total_sales' => 'floatval', 'num_items_sold' => 'intval', 'customer_type' => 'strval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'orders'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); // Avoid ambiguous columns in SQL query. $this->report_columns = array( 'order_id' => "DISTINCT {$table_name}.order_id", 'parent_id' => "{$table_name}.parent_id", // Add 'date' field based on date type setting. 'date' => "{$table_name}.{$this->date_column_name} AS date", 'date_created' => "{$table_name}.date_created", 'date_created_gmt' => "{$table_name}.date_created_gmt", 'status' => "REPLACE({$table_name}.status, 'wc-', '') as status", 'customer_id' => "{$table_name}.customer_id", 'net_total' => "{$table_name}.net_total", 'total_sales' => "{$table_name}.total_sales", 'num_items_sold' => "{$table_name}.num_items_sold", 'customer_type' => "(CASE WHEN {$table_name}.returning_customer = 0 THEN 'new' ELSE 'returning' END) as customer_type", ); } /** * Updates the database query with parameters used for orders report: coupons and products filters. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_stats_lookup_table = self::get_db_table_name(); $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $order_tax_lookup_table = $wpdb->prefix . 'wc_order_tax_lookup'; $operator = $this->get_match_operator( $query_args ); $where_subquery = array(); $have_joined_products_table = false; $this->add_time_period_sql_params( $query_args, $order_stats_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $status_subquery = $this->get_status_subquery( $query_args ); if ( $status_subquery ) { if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) { $this->subquery->add_sql_clause( 'where', "AND {$status_subquery}" ); } else { $where_subquery[] = $status_subquery; } } $included_orders = $this->get_included_orders( $query_args ); if ( $included_orders ) { $where_subquery[] = "{$order_stats_lookup_table}.order_id IN ({$included_orders})"; } $excluded_orders = $this->get_excluded_orders( $query_args ); if ( $excluded_orders ) { $where_subquery[] = "{$order_stats_lookup_table}.order_id NOT IN ({$excluded_orders})"; } if ( $query_args['customer_type'] ) { $returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0; $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}"; } $refund_subquery = $this->get_refund_subquery( $query_args ); $this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] ); if ( $refund_subquery['where_clause'] ) { $where_subquery[] = $refund_subquery['where_clause']; } $included_coupons = $this->get_included_coupons( $query_args ); $excluded_coupons = $this->get_excluded_coupons( $query_args ); if ( $included_coupons || $excluded_coupons ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id" ); } if ( $included_coupons ) { $where_subquery[] = "{$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; } if ( $excluded_coupons ) { $where_subquery[] = "({$order_coupon_lookup_table}.coupon_id IS NULL OR {$order_coupon_lookup_table}.coupon_id NOT IN ({$excluded_coupons}))"; } $included_products = $this->get_included_products( $query_args ); $excluded_products = $this->get_excluded_products( $query_args ); if ( $included_products || $excluded_products ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} product_lookup" ); $this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = product_lookup.order_id" ); } if ( $included_products ) { $this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$included_products})" ); $where_subquery[] = 'product_lookup.order_id IS NOT NULL'; } if ( $excluded_products ) { $this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$excluded_products})" ); $where_subquery[] = 'product_lookup.order_id IS NULL'; } $included_variations = $this->get_included_variations( $query_args ); $excluded_variations = $this->get_excluded_variations( $query_args ); if ( $included_variations || $excluded_variations ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} variation_lookup" ); $this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = variation_lookup.order_id" ); } if ( $included_variations ) { $this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$included_variations})" ); $where_subquery[] = 'variation_lookup.order_id IS NOT NULL'; } if ( $excluded_variations ) { $this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$excluded_variations})" ); $where_subquery[] = 'variation_lookup.order_id IS NULL'; } $included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_includes'] ) ) : false; $excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_excludes'] ) ) : false; if ( $included_tax_rates || $excluded_tax_rates ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_tax_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_tax_lookup_table}.order_id" ); } if ( $included_tax_rates ) { $where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id IN ({$included_tax_rates})"; } if ( $excluded_tax_rates ) { $where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id NOT IN ({$excluded_tax_rates}) OR {$order_tax_lookup_table}.tax_rate_id IS NULL"; } $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" ); // Add JOINs for matching attributes. foreach ( $attribute_subqueries['join'] as $attribute_join ) { $this->subquery->add_sql_clause( 'join', $attribute_join ); } // Add WHEREs for matching attributes. $where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] ); } if ( 0 < count( $where_subquery ) ) { $this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = array_merge( parent::get_default_query_vars(), array( 'orderby' => $this->date_column_name, 'product_includes' => array(), 'product_excludes' => array(), 'coupon_includes' => array(), 'coupon_excludes' => array(), 'tax_rate_includes' => array(), 'tax_rate_excludes' => array(), 'customer_type' => null, 'status_is' => array(), 'extended_info' => false, 'refunds' => null, 'order_includes' => array(), 'order_excludes' => array(), ) ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT( DISTINCT tt.order_id ) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); /* phpcs:enable */ if ( 0 === $params['per_page'] ) { $total_pages = 0; } else { $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); } if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { $data = (object) array( 'data' => array(), 'total' => $db_records_count, 'pages' => 0, 'page_no' => 0, ); return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $orders_data = $wpdb->get_results( $this->subquery->get_query_statement(), ARRAY_A ); /* phpcs:enable */ if ( null === $orders_data ) { return $data; } if ( $query_args['extended_info'] ) { $this->include_extended_info( $orders_data, $query_args ); } $orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data ); $data = (object) array( 'data' => $orders_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return $this->date_column_name; } return $order_by; } /** * Enriches the order data. * * @param array $orders_data Orders data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$orders_data, $query_args ) { $mapped_orders = $this->map_array_by_key( $orders_data, 'order_id' ); $related_orders = $this->get_orders_with_parent_id( $mapped_orders ); $order_ids = array_merge( array_keys( $mapped_orders ), array_keys( $related_orders ) ); $products = $this->get_products_by_order_ids( $order_ids ); $coupons = $this->get_coupons_by_order_ids( array_keys( $mapped_orders ) ); $order_attributions = $this->get_order_attributions_by_order_ids( array_keys( $mapped_orders ) ); $customers = $this->get_customers_by_orders( $orders_data ); $mapped_customers = $this->map_array_by_key( $customers, 'customer_id' ); $mapped_data = array(); foreach ( $products as $product ) { if ( ! isset( $mapped_data[ $product['order_id'] ] ) ) { $mapped_data[ $product['order_id'] ]['products'] = array(); } $is_variation = '0' !== $product['variation_id']; $product_data = array( 'id' => $is_variation ? $product['variation_id'] : $product['product_id'], 'name' => $product['product_name'], 'quantity' => $product['product_quantity'], ); if ( $is_variation ) { $variation = wc_get_product( $product_data['id'] ); /** * Used to determine the separator for products and their variations titles. * * @since 4.0.0 */ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $variation ); if ( false === strpos( $product_data['name'], $separator ) ) { $attributes = wc_get_formatted_variation( $variation, true, false ); $product_data['name'] .= $separator . $attributes; } } $mapped_data[ $product['order_id'] ]['products'][] = $product_data; // If this product's order has another related order, it will be added to our mapped_data. if ( isset( $related_orders [ $product['order_id'] ] ) ) { $mapped_data[ $related_orders[ $product['order_id'] ]['order_id'] ] ['products'] [] = $product_data; } } foreach ( $coupons as $coupon ) { if ( ! isset( $mapped_data[ $coupon['order_id'] ] ) ) { $mapped_data[ $coupon['order_id'] ]['coupons'] = array(); } $mapped_data[ $coupon['order_id'] ]['coupons'][] = array( 'id' => $coupon['coupon_id'], 'code' => wc_format_coupon_code( $coupon['coupon_code'] ), ); } foreach ( $orders_data as $key => $order_data ) { $defaults = array( 'products' => array(), 'coupons' => array(), 'customer' => array(), 'attribution' => array(), ); $order_id = $order_data['order_id']; $orders_data[ $key ]['extended_info'] = isset( $mapped_data[ $order_id ] ) ? array_merge( $defaults, $mapped_data[ $order_id ] ) : $defaults; if ( $order_data['customer_id'] && isset( $mapped_customers[ $order_data['customer_id'] ] ) ) { $orders_data[ $key ]['extended_info']['customer'] = $mapped_customers[ $order_data['customer_id'] ]; } $source_type = $order_attributions[ $order_id ]['_wc_order_attribution_source_type'] ?? ''; $utm_source = $order_attributions[ $order_id ]['_wc_order_attribution_utm_source'] ?? ''; $orders_data[ $key ]['extended_info']['attribution']['origin'] = $this->get_origin_label( $source_type, $utm_source ); } } /** * Returns oreders that have a parent id * * @param array $orders Orders array. * @return array */ protected function get_orders_with_parent_id( $orders ) { $related_orders = array(); foreach ( $orders as $order ) { if ( '0' !== $order['parent_id'] ) { $related_orders[ $order['parent_id'] ] = $order; } } return $related_orders; } /** * Returns the same array index by a given key * * @param array $array Array to be looped over. * @param string $key Key of values used for new array. * @return array */ protected function map_array_by_key( $array, $key ) { $mapped = array(); foreach ( $array as $item ) { $mapped[ $item[ $key ] ] = $item; } return $mapped; } /** * Get product IDs, names, and quantity from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_products_by_order_ids( $order_ids ) { global $wpdb; $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $included_order_ids = implode( ',', $order_ids ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $products = $wpdb->get_results( "SELECT order_id, product_id, variation_id, post_title as product_name, product_qty as product_quantity FROM {$wpdb->posts} JOIN {$order_product_lookup_table} ON {$wpdb->posts}.ID = ( CASE WHEN variation_id > 0 THEN variation_id ELSE product_id END ) WHERE order_id IN ({$included_order_ids}) AND product_qty > 0 ", ARRAY_A ); /* phpcs:enable */ return $products; } /** * Get customer data from Order data. * * @param array $orders Array of orders data. * @return array */ protected function get_customers_by_orders( $orders ) { global $wpdb; $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer_ids = array(); foreach ( $orders as $order ) { if ( $order['customer_id'] ) { $customer_ids[] = intval( $order['customer_id'] ); } } if ( empty( $customer_ids ) ) { return array(); } /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $customer_ids = implode( ',', $customer_ids ); $customers = $wpdb->get_results( "SELECT * FROM {$customer_lookup_table} WHERE customer_id IN ({$customer_ids})", ARRAY_A ); /* phpcs:enable */ return $customers; } /** * Get coupon information from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_coupons_by_order_ids( $order_ids ) { global $wpdb; $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; $included_order_ids = implode( ',', $order_ids ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $coupons = $wpdb->get_results( "SELECT order_id, coupon_id, post_title as coupon_code FROM {$wpdb->posts} JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->posts}.ID WHERE order_id IN ({$included_order_ids}) ", ARRAY_A ); /* phpcs:enable */ return $coupons; } /** * Get order attributions data from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_order_attributions_by_order_ids( $order_ids ) { global $wpdb; $order_meta_table = OrdersTableDataStore::get_meta_table_name(); $included_order_ids = implode( ',', array_map( 'absint', $order_ids ) ); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $order_attributions_meta = $wpdb->get_results( "SELECT order_id, meta_key, meta_value FROM $order_meta_table WHERE order_id IN ({$included_order_ids}) AND meta_key IN ( '_wc_order_attribution_source_type', '_wc_order_attribution_utm_source' ) ", ARRAY_A ); /* phpcs:enable */ } else { /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $order_attributions_meta = $wpdb->get_results( "SELECT post_id as order_id, meta_key, meta_value FROM $wpdb->postmeta WHERE post_id IN ({$included_order_ids}) AND meta_key IN ( '_wc_order_attribution_source_type', '_wc_order_attribution_utm_source' ) ", ARRAY_A ); /* phpcs:enable */ } $order_attributions = array(); foreach ( $order_attributions_meta as $meta ) { if ( ! isset( $order_attributions[ $meta['order_id'] ] ) ) { $order_attributions[ $meta['order_id'] ] = array(); } $order_attributions[ $meta['order_id'] ][ $meta['meta_key'] ] = $meta['meta_value']; } return $order_attributions; } /** * Get all statuses that have been synced. * * @return string[] Unique order statuses. */ public static function get_all_statuses() { global $wpdb; $statuses = wp_cache_get( self::ORDERS_STATUSES_ALL_CACHE_KEY, 'woocommerce_analytics' ); if ( false === $statuses ) { $table_name = self::get_db_table_name(); $statuses = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT status FROM %i', $table_name ) ); wp_cache_set( self::ORDERS_STATUSES_ALL_CACHE_KEY, $statuses, 'woocommerce_analytics', YEAR_IN_SECONDS ); } return $statuses; } /** * Ensure the order status will present in `get_all_statuses` call result. * * @internal * @param int $order_id Order ID. * @return void */ public static function maybe_update_order_statuses_cache( $order_id ) { $order = wc_get_order( $order_id ); if ( $order ) { $status = self::normalize_order_status( $order->get_status() ); $statuses = self::get_all_statuses(); if ( ! in_array( $status, $statuses, true ) ) { $statuses[] = $status; wp_cache_set( self::ORDERS_STATUSES_ALL_CACHE_KEY, $statuses, 'woocommerce_analytics', YEAR_IN_SECONDS ); } } } /** * Ensure the order status will present in `get_all_statuses` call result. * * @deprecated 10.3.0 Use maybe_update_order_statuses_cache(). * @param int $order_id Order ID. * @return void */ public static function maybe_update_order_statuses_transient( $order_id ) { wc_deprecated_function( __METHOD__, '10.3.0', __CLASS__ . '::maybe_update_order_statuses_cache()' ); self::maybe_update_order_statuses_cache( $order_id ); } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.order_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); } } Reports/OrderAwareControllerTrait.php 0000777 00000007361 15252174340 0014037 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Trait to contain shared methods for reports Controllers that use order and orders statuses. * * If your analytics controller needs to work with orders, * you will most probably need to use at least {@see get_order_statuses() get_order_statuses()} * to filter only "actionable" statuses to produce consistent results among other analytics. * * @see GenericController */ trait OrderAwareControllerTrait { /** * Get the order number for an order. If no filter is present for `woocommerce_order_number`, we can just return the ID. * Returns the parent order number if the order is actually a refund. * * @param int $order_id Order ID. * @return string|null The Order Number or null if the order doesn't exist. */ protected function get_order_number( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $this->is_valid_order( $order ) ) { return null; } if ( 'shop_order_refund' === $order->get_type() ) { $order = wc_get_order( $order->get_parent_id() ); // If the parent order doesn't exist, return null. if ( ! $this->is_valid_order( $order ) ) { return null; } } if ( ! has_filter( 'woocommerce_order_number' ) ) { return $order->get_id(); } return $order->get_order_number(); } /** * Whether the order is valid. * * @param bool|WC_Order|WC_Order_Refund $order Order object. * @return bool True if the order is valid, false otherwise. */ protected function is_valid_order( $order ) { return $order instanceof \WC_Order || $order instanceof \WC_Order_Refund; } /** * Get the order total with the related currency formatting. * Returns the parent order total if the order is actually a refund. * * @param int $order_id Order ID. * @return string|null The Order Number or null if the order doesn't exist. */ protected function get_total_formatted( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $this->is_valid_order( $order ) ) { return null; } if ( 'shop_order_refund' === $order->get_type() ) { $order = wc_get_order( $order->get_parent_id() ); if ( ! $this->is_valid_order( $order ) ) { return null; } } return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true ); } /** * Get order statuses without prefixes. * Includes unregistered statuses that have been marked "actionable". * * @return array */ public static function get_order_statuses() { // Allow all statuses selected as "actionable" - this may include unregistered statuses. // See: https://github.com/woocommerce/woocommerce-admin/issues/5592. $actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() ); // Prevent errors if the database entry is not the expected type (array). if ( ! is_array( $actionable_statuses ) ) { $actionable_statuses = array(); } // See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses. $registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) ); // Merge the status arrays (using flip to avoid array_unique()). $allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) ); return $allowed_statuses; } /** * Get order statuses (and labels) without prefixes. * * @internal * @return array */ public static function get_order_status_labels() { $order_statuses = array(); foreach ( wc_get_order_statuses() as $key => $label ) { $new_key = str_replace( 'wc-', '', $key ); $order_statuses[ $new_key ] = $label; } return $order_statuses; } } Reports/PerformanceIndicators/Controller.php 0000777 00000046466 15252174340 0015351 0 ustar 00 <?php /** * REST API Performance indicators controller * * Handles requests to the /reports/store-performance endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * REST API Reports Performance indicators controller class. * * @internal * @extends GenericController */ class Controller extends GenericController { /** * Route base. * * @var string */ protected $rest_base = 'reports/performance-indicators'; /** * Contains a list of endpoints by report slug. * * @var array */ protected $endpoints = array(); /** * Contains a list of active Jetpack module slugs. * * @var array */ protected $active_jetpack_modules = null; /** * Contains a list of allowed stats. * * @var array */ protected $allowed_stats = array(); /** * Contains a list of stat labels. * * @var array */ protected $labels = array(); /** * Contains a list of endpoints by url. * * @var array */ protected $urls = array(); /** * Contains a cache of retrieved stats data, grouped by report slug. * * @var array */ protected $stats_data = array(); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_rest_performance_indicators_data_value', array( $this, 'format_data_value' ), 10, 5 ); } /** * Register the routes for reports. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, '/' . $this->rest_base . '/allowed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_allowed_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_allowed_item_schema' ), ) ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['stats'] = $request['stats']; return $args; } /** * Get analytics report data and endpoints. */ private function get_analytics_report_data() { $request = new \WP_REST_Request( 'GET', '/wc-analytics/reports' ); /** * Performance hack to strip the `rel=self` link from the report response as it is built by the Reports/Controller * to avoid the expensive calls to WP_REST_Server::get_target_hints_for_link(). * * @param WP_REST_Response $response The response object. * * @return mixed */ $remove_self_link_from_prepared_internal_response = function ( $response ) { if ( is_callable( array( $response, 'remove_link' ) ) ) { $response->remove_link( 'self' ); } return $response; }; add_filter( 'woocommerce_rest_prepare_report', $remove_self_link_from_prepared_internal_response ); $response = rest_do_request( $request ); remove_filter( 'woocommerce_rest_prepare_report', $remove_self_link_from_prepared_internal_response ); if ( is_wp_error( $response ) ) { return $response; } if ( 200 !== $response->get_status() ) { return new \WP_Error( 'woocommerce_analytics_performance_indicators_result_failed', __( 'Sorry, fetching performance indicators failed.', 'woocommerce' ) ); } $endpoints = $response->get_data(); foreach ( $endpoints as $endpoint ) { if ( '/stats' === substr( $endpoint['slug'], -6 ) ) { $request = new \WP_REST_Request( 'OPTIONS', $endpoint['path'] ); $response = rest_do_request( $request ); if ( is_wp_error( $response ) ) { return $response; } $data = $response->get_data(); $prefix = substr( $endpoint['slug'], 0, -6 ); if ( empty( $data['schema']['properties']['totals']['properties'] ) ) { continue; } foreach ( $data['schema']['properties']['totals']['properties'] as $property_key => $schema_info ) { if ( empty( $schema_info['indicator'] ) || ! $schema_info['indicator'] ) { continue; } $stat = $prefix . '/' . $property_key; $this->allowed_stats[] = $stat; $stat_label = empty( $schema_info['title'] ) ? $schema_info['description'] : $schema_info['title']; $this->labels[ $stat ] = trim( $stat_label, '.' ); $this->formats[ $stat ] = isset( $schema_info['format'] ) ? $schema_info['format'] : 'number'; } $this->endpoints[ $prefix ] = $endpoint['path']; $this->urls[ $prefix ] = $endpoint['_links']['report'][0]['href']; } } } /** * Get active Jetpack modules. * * @return array List of active Jetpack module slugs. */ private function get_active_jetpack_modules() { if ( is_null( $this->active_jetpack_modules ) ) { if ( class_exists( '\Jetpack' ) && method_exists( '\Jetpack', 'get_active_modules' ) ) { $active_modules = \Jetpack::get_active_modules(); $this->active_jetpack_modules = is_array( $active_modules ) ? $active_modules : array(); } else { $this->active_jetpack_modules = array(); } } return $this->active_jetpack_modules; } /** * Set active Jetpack modules. * * @internal * @param array $modules List of active Jetpack module slugs. */ public function set_active_jetpack_modules( $modules ) { $this->active_jetpack_modules = $modules; } /** * Get active Jetpack modules and endpoints. */ private function get_jetpack_modules_data() { $active_modules = $this->get_active_jetpack_modules(); if ( empty( $active_modules ) ) { return; } $items = apply_filters( 'woocommerce_rest_performance_indicators_jetpack_items', array( 'stats/visitors' => array( 'label' => __( 'Visitors', 'woocommerce' ), 'permission' => 'view_stats', 'format' => 'number', 'module' => 'stats', ), 'stats/views' => array( 'label' => __( 'Views', 'woocommerce' ), 'permission' => 'view_stats', 'format' => 'number', 'module' => 'stats', ), ) ); foreach ( $items as $item_key => $item ) { if ( ! in_array( $item['module'], $active_modules, true ) ) { return; } if ( $item['permission'] && ! current_user_can( $item['permission'] ) ) { return; } $stat = 'jetpack/' . $item_key; $endpoint = 'jetpack/' . $item['module']; $this->allowed_stats[] = $stat; $this->labels[ $stat ] = $item['label']; $this->endpoints[ $endpoint ] = '/jetpack/v4/module/' . $item['module'] . '/data'; $this->formats[ $stat ] = $item['format']; } $this->urls['jetpack/stats'] = '/jetpack'; } /** * Get information such as allowed stats, stat labels, and endpoint data from stats reports. * * @return WP_Error|True */ private function get_indicator_data() { // Data already retrieved. if ( ! empty( $this->endpoints ) && ! empty( $this->labels ) && ! empty( $this->allowed_stats ) ) { return true; } $this->get_analytics_report_data(); $this->get_jetpack_modules_data(); return true; } /** * Returns a list of allowed performance indicators. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_allowed_items( $request ) { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { return $indicator_data; } $data = array(); foreach ( $this->allowed_stats as $stat ) { $pieces = $this->get_stats_parts( $stat ); $report = $pieces[0]; $chart = $pieces[1]; $data[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $this->labels[ $stat ], ); } usort( $data, array( $this, 'sort' ) ); $objects = array(); foreach ( $data as $item ) { $prepared = $this->prepare_item_for_response( $item, $request ); $objects[] = $this->prepare_response_for_collection( $prepared ); } return $this->add_pagination_headers( $request, $objects, (int) count( $data ), 1, 1 ); } /** * Sorts the list of stats. Sorted by custom arrangement. * * @internal * @see https://github.com/woocommerce/woocommerce-admin/issues/1282 * @param object $a First item. * @param object $b Second item. * @return order */ public function sort( $a, $b ) { /** * Custom ordering for store performance indicators. * * @see https://github.com/woocommerce/woocommerce-admin/issues/1282 * @param array $indicators A list of ordered indicators. */ $stat_order = apply_filters( 'woocommerce_rest_report_sort_performance_indicators', array( 'revenue/total_sales', 'revenue/net_revenue', 'orders/orders_count', 'orders/avg_order_value', 'products/items_sold', 'revenue/refunds', 'coupons/orders_count', 'coupons/amount', 'taxes/total_tax', 'taxes/order_tax', 'taxes/shipping_tax', 'revenue/shipping', 'downloads/download_count', ) ); $a = array_search( $a->stat, $stat_order, true ); $b = array_search( $b->stat, $stat_order, true ); if ( false === $a && false === $b ) { return 0; } elseif ( false === $a ) { return 1; } elseif ( false === $b ) { return -1; } else { return $a - $b; } } /** * Get report stats data, avoiding duplicate requests for stats that use the same endpoint. * * @param string $report Report slug to request data for. * @param array $query_args Report query args. * @return WP_REST_Response|WP_Error Report stats data. */ private function get_stats_data( $report, $query_args ) { // Return from cache if we've already requested these report stats. if ( isset( $this->stats_data[ $report ] ) ) { return $this->stats_data[ $report ]; } // Request the report stats. $request_url = $this->endpoints[ $report ]; $request = new \WP_REST_Request( 'GET', $request_url ); $request->set_param( 'before', $query_args['before'] ); $request->set_param( 'after', $query_args['after'] ); $response = rest_do_request( $request ); // Cache the response. $this->stats_data[ $report ] = $response; return $response; } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { return $indicator_data; } $query_args = $this->prepare_reports_query( $request ); if ( empty( $query_args['stats'] ) ) { return new \WP_Error( 'woocommerce_analytics_performance_indicators_empty_query', __( 'A list of stats to query must be provided.', 'woocommerce' ), 400 ); } $stats = array(); foreach ( $query_args['stats'] as $stat ) { $is_error = false; $pieces = $this->get_stats_parts( $stat ); $report = $pieces[0]; $chart = $pieces[1]; if ( ! in_array( $stat, $this->allowed_stats, true ) ) { continue; } $response = $this->get_stats_data( $report, $query_args ); if ( is_wp_error( $response ) ) { return $response; } $data = $response->get_data(); $format = $this->formats[ $stat ]; $label = $this->labels[ $stat ]; if ( 200 !== $response->get_status() ) { $stats[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $label, 'format' => $format, 'value' => null, ); continue; } $stats[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $label, 'format' => $format, 'value' => apply_filters( 'woocommerce_rest_performance_indicators_data_value', $data, $stat, $report, $chart, $query_args ), ); } usort( $stats, array( $this, 'sort' ) ); $objects = array(); foreach ( $stats as $stat ) { $data = $this->prepare_item_for_response( $stat, $request ); $objects[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $objects ); $response->header( 'X-WP-Total', count( $stats ) ); $response->header( 'X-WP-TotalPages', 1 ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); return $response; } /** * Prepare a report data item for serialization. * * @param array $stat_data Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $stat_data, $request ) { $response = parent::prepare_item_for_response( $stat_data, $request ); $response->add_links( $this->prepare_links( $stat_data ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_performance_indicators', $response, $stat_data, $request ); } /** * Prepare links for the request. * * @param object $object data. * @return array */ protected function prepare_links( $object ) { $pieces = $this->get_stats_parts( $object->stat ); $endpoint = $pieces[0]; $stat = $pieces[1]; $url = isset( $this->urls[ $endpoint ] ) ? $this->urls[ $endpoint ] : ''; $links = array( 'api' => array( 'href' => rest_url( $this->endpoints[ $endpoint ] ), ), 'report' => array( 'href' => $url, ), ); return $links; } /** * Returns the endpoint part of a stat request (prefix) and the actual stat total we want. * To allow extensions to namespace (example: fue/emails/sent), we break on the last forward slash. * * @param string $full_stat A stat request string like orders/avg_order_value or fue/emails/sent. * @return array Containing the prefix (endpoint) and suffix (stat). */ private function get_stats_parts( $full_stat ) { $endpoint = substr( $full_stat, 0, strrpos( $full_stat, '/' ) ); $stat = substr( $full_stat, ( strrpos( $full_stat, '/' ) + 1 ) ); return array( $endpoint, $stat, ); } /** * Format the data returned from the API for given stats. * * @param array $data Data from external endpoint. * @param string $stat Name of the stat. * @param string $report Name of the report. * @param string $chart Name of the chart. * @param array $query_args Query args. * @return mixed */ public function format_data_value( $data, $stat, $report, $chart, $query_args ) { if ( 'jetpack/stats' === $report ) { $index = false; // Get the index of the field to tally. if ( isset( $data['general']->visits->fields ) && is_array( $data['general']->visits->fields ) ) { $index = array_search( $chart, $data['general']->visits->fields, true ); } if ( ! $index ) { return null; } // Loop over provided data and filter by the queried date. // Note that this is currently limited to 30 days via the Jetpack API // but the WordPress.com endpoint allows up to 90 days. $total = 0; $before = gmdate( 'Y-m-d', strtotime( isset( $query_args['before'] ) ? $query_args['before'] : TimeInterval::default_before() ) ); $after = gmdate( 'Y-m-d', strtotime( isset( $query_args['after'] ) ? $query_args['after'] : TimeInterval::default_after() ) ); foreach ( $data['general']->visits->data as $datum ) { if ( $datum[0] >= $after && $datum[0] <= $before ) { $total += $datum[ $index ]; } } return $total; } if ( isset( $data['totals'] ) && isset( $data['totals'][ $chart ] ) ) { return $data['totals'][ $chart ]; } return null; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { $allowed_stats = array(); } else { $allowed_stats = $this->allowed_stats; } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_performance_indicator', 'type' => 'object', 'properties' => array( 'stat' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => $allowed_stats, ), 'chart' => array( 'description' => __( 'The specific chart this stat referrers to.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'label' => array( 'description' => __( 'Human readable label for the stat.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'format' => array( 'description' => __( 'Format of the stat.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'number', 'currency' ), ), 'value' => array( 'description' => __( 'Value of the stat. Returns null if the stat does not exist or cannot be loaded.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get schema for the list of allowed performance indicators. * * @return array $schema */ public function get_public_allowed_item_schema() { $schema = $this->get_public_item_schema(); unset( $schema['properties']['value'] ); unset( $schema['properties']['format'] ); return $schema; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { $allowed_stats = __( 'There was an issue loading the report endpoints', 'woocommerce' ); } else { $allowed_stats = implode( ', ', $this->allowed_stats ); } $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['stats'] = array( 'description' => sprintf( /* translators: Allowed values is a list of stat endpoints. */ __( 'Limit response to specific report stats. Allowed values: %s.', 'woocommerce' ), $allowed_stats ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', 'enum' => $this->allowed_stats, ), 'default' => $this->allowed_stats, ); $params['after'] = array( 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['before'] = array( 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Products/Stats/Query.php 0000777 00000003720 15252174340 0012735 0 ustar 00 <?php /** * Class for parameter-based Products Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Stats\Query * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_products_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_products_stats_select_query', $results, $args ); } } Reports/Products/Stats/Segmenter.php 0000777 00000024401 15252174340 0013560 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'items_sold' => "SUM($products_table.product_qty) as items_sold", 'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue", 'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count", 'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count", 'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count", ); return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to a multiple of the number of segments. preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts ); $segment_count = count( $this->get_all_segments() ); $orig_offset = intval( $limit_parts[1] ); $orig_rowcount = intval( $limit_parts[2] ); $segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = " LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; // Restrict our search space for category comparisons. if ( isset( $this->query_args['category_includes'] ) ) { $category_ids = implode( ',', $this->get_all_segments() ); $segmenting_where .= " AND {$wpdb->wc_category_lookup}.category_id IN ( $category_ids )"; } $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } return $segments; } } Reports/Products/Stats/Controller.php 0000777 00000016340 15252174340 0013755 0 ustar 00 <?php /** * REST API Reports products stats controller * * Handles requests to the /reports/products/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports products stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/products/stats'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'categories' => 'category_includes', 'products' => 'product_includes', 'variations' => 'variation_includes', ); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_products_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Get data from `'products-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'products-stats' ); return $query->get_data(); } /** * Maps query arguments from the REST request to be used to query the datastore. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', 'net_revenue', 'orders_count', 'products_count', 'variations_count', ), ); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $query_args[ $param_name ] = $request[ $param_name ]; } } } return $query_args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_products_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'items_sold' => array( 'title' => __( 'Products sold', 'woocommerce' ), 'description' => __( 'Number of product items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_products_stats'; $segment_label = array( 'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ); $schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; return $this->add_additional_fields_schema( $schema ); } /** * Set the default results to 0 if API returns an empty array * * @internal * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->items_sold = 0; $results->totals->net_revenue = 0; $results->totals->orders_count = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', ) ); $params['categories'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Products/Stats/DataStore.php 0000777 00000023126 15252174340 0013520 0 ustar 00 <?php /** * API\Reports\Products\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Products\Stats\DataStore. */ class DataStore extends ProductsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override ProductsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'products_count' => 'intval', 'variations_count' => 'intval', ); /** * Cache identifier. * * @override ProductsDataStore::$cache_key * * @var string */ protected $cache_key = 'products_stats'; /** * Data store context used to pass to filters. * * @override ProductsDataStore::$context * * @var string */ protected $context = 'products_stats'; /** * Assign report columns once full table name has been assigned. * * @override ProductsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", 'products_count' => 'COUNT(DISTINCT product_id) as products_count', 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $products_where_clause = ''; $products_from_clause = ''; $order_product_lookup_table = self::get_db_table_name(); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $products_where_clause .= " AND ( {$order_status_filter} )"; } $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->total_query->add_sql_clause( 'where', $products_where_clause ); $this->total_query->add_sql_clause( 'join', $products_from_clause ); $this->add_intervals_sql_params( $query_args, $order_product_lookup_table ); $this->interval_query->add_sql_clause( 'where', $products_where_clause ); $this->interval_query->add_sql_clause( 'join', $products_from_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ProductsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['interval'] = 'week'; unset( $defaults['extended_info'] ); return $defaults; } /** * Returns the report data based on parameters supplied by the user. * * @override ProductsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { // Do not include extended info like `ProductsDataStore` does. return ReportsDataStore::get_data( $query_args ); } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ProductsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->update_sql_query_params( $query_args ); $this->get_limit_sql_params( $query_args ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $intervals = array(); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'order_by' => $this->get_sql_clause( 'order_by' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override ProductsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } Reports/Products/DataStore.php 0000777 00000056644 15252174340 0012435 0 ustar 00 <?php /** * API\Reports\Products\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Utilities\OrderUtil; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Enums\ProductType; /** * API\Reports\Products\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'products'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', // Extended attributes. 'name' => 'strval', 'price' => 'floatval', 'image' => 'strval', 'permalink' => 'strval', 'stock_status' => 'strval', 'stock_quantity' => 'intval', 'low_stock_amount' => 'intval', 'category_ids' => 'array_values', 'variations' => 'array_values', 'sku' => 'strval', ); /** * Extended product attributes to include in the data. * * @var array */ protected $extended_attributes = array( 'name', 'price', 'image', 'permalink', 'stock_status', 'stock_quantity', 'manage_stock', 'low_stock_amount', 'category_ids', 'variations', 'sku', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'products'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'product_id' => 'product_id', 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 10 ); add_action( 'woocommerce_order_partially_refunded', array( __CLASS__, 'add_partial_refund_type_meta' ), 10, 2 ); add_action( 'woocommerce_order_fully_refunded', array( __CLASS__, 'add_full_refund_type_meta' ), 10, 2 ); } /** * Add a partial refund type meta to the order. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public static function add_partial_refund_type_meta( $order_id, $refund_id ) { self::add_refund_type_meta( $refund_id, 'partial' ); } /** * Add a full refund type meta to the order. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public static function add_full_refund_type_meta( $order_id, $refund_id ) { self::add_refund_type_meta( $refund_id, 'full' ); } /** * Add a refund type meta to the order. * * @param int $refund_id Refund ID. * @param string $type Refund type. */ public static function add_refund_type_meta( $refund_id, $type ) { $order = wc_get_order( $refund_id ); $order->update_meta_data( '_refund_type', $type ); $order->save_meta_data(); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $arg_name Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_from_sql_params( $query_args, $arg_name, $id_cell ) { global $wpdb; $type = 'join'; // Order by product name requires extra JOIN. switch ( $query_args['orderby'] ) { case 'product_name': $join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID"; break; case 'sku': $join = " LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'"; break; case 'variations': $type = 'left_join'; $join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent"; break; default: $join = ''; break; } if ( $join ) { if ( 'inner' === $arg_name ) { $this->subquery->add_sql_clause( $type, $join ); } else { $this->add_sql_clause( $type, $join ); } } } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' ); $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } else { $this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" ); } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return self::get_db_table_name() . '.date_created'; } if ( 'product_name' === $order_by ) { return 'post_title'; } if ( 'sku' === $order_by ) { return 'meta_value'; } return $order_by; } /** * Enriches the product data with attributes specified by the extended_attributes. * * @param array $products_data Product data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$products_data, $query_args ) { global $wpdb; $product_names = array(); foreach ( $products_data as $key => $product_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $product_id = $product_data['product_id']; $product = wc_get_product( $product_id ); // Product was deleted. if ( ! $product ) { if ( ! isset( $product_names[ $product_id ] ) ) { $product_names[ $product_id ] = $wpdb->get_var( $wpdb->prepare( "SELECT i.order_item_name FROM {$wpdb->prefix}wc_order_product_lookup l JOIN {$wpdb->prefix}woocommerce_order_items i ON i.order_item_id = l.order_item_id WHERE l.product_id = %d ORDER BY l.order_item_id DESC LIMIT 1", $product_id ) ); } /* translators: %s is product name */ $products_data[ $key ]['extended_info']['name'] = $product_names[ $product_id ] ? sprintf( __( '%s (Deleted)', 'woocommerce' ), $product_names[ $product_id ] ) : __( '(Deleted)', 'woocommerce' ); continue; } $extended_attributes = apply_filters( 'woocommerce_rest_reports_products_extended_attributes', $this->extended_attributes, $product_data ); foreach ( $extended_attributes as $extended_attribute ) { if ( 'variations' === $extended_attribute ) { if ( ! $product->is_type( ProductType::VARIABLE ) ) { continue; } $function = 'get_children'; } else { $function = 'get_' . $extended_attribute; } if ( is_callable( array( $product, $function ) ) ) { $value = $product->{$function}(); $extended_info[ $extended_attribute ] = $value; } } // If there is no set low_stock_amount, use the one in user settings. if ( '' === $extended_info['low_stock_amount'] ) { $extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $extended_info = $this->cast_numbers( $extended_info ); } $products_data[ $key ]['extended_info'] = $extended_info; } } /** * Returns the report data based on parameters supplied by the user. * * @override ReportsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { $data = parent::get_data( $query_args ); /* * Do not cache extended info -- this is required to get the latest stock data. * `include_extended_info` checks only `extended_info` key, * so we don't need to bother about normalizing timestamps. */ $defaults = $this->get_default_query_vars(); $query_args = wp_parse_args( $query_args, $defaults ); $this->include_extended_info( $data->data, $query_args ); return $data; } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['product_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_products = $this->get_included_products_array( $query_args ); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_products ) > 0 ) { $filtered_products = array_diff( $included_products, array( '-1' ) ); $total_results = count( $filtered_products ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); if ( 'date' === $query_args['orderby'] ) { $selections .= ", {$table_name}.date_created"; } $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'product_id' ) ); $ids_table = $this->get_ids_table( $included_products, 'product_id' ); $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->add_sql_clause( 'select', $join_selections ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.product_id = {$table_name}.product_id" ); $this->add_sql_clause( 'where', 'AND default_results.product_id != -1' ); $products_query = $this->get_query_statement(); } else { $count_query = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt"; $db_records_count = (int) $wpdb->get_var( $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); if ( in_array( $query_args['orderby'], array( 'items_sold', 'net_revenue', 'orders_count', 'variations' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', product_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $products_query = $this->subquery->get_query_statement(); } $product_data = $wpdb->get_results( $products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $product_data ) { return $data; } $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); $data = (object) array( 'data' => $product_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Create or update an entry in the wc_admin_order_product_lookup table for an order. * * @since 3.5.0 * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_products( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } $table_name = self::get_db_table_name(); $existing_items = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_item_id FROM {$table_name} WHERE order_id = %d", $order_id ) ); $existing_items = array_flip( $existing_items ); $order_items = $order->get_items(); $num_updated = 0; $decimals = wc_get_price_decimals(); $round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' ); $is_full_refund_without_line_items = false; $partial_refund_product_revenue = array(); $refund_type = $order->get_meta( '_refund_type' ); $uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data(); $parent_order = null; // When changing the order status to "Refunded", the refund order's type will be full refund, and the order items will be empty. // We need to get the parent order items, and exclude the items that are already being partially refunded. if ( 'shop_order_refund' === $order->get_type() && 'full' === $refund_type && empty( $order_items ) && $uses_new_full_refund_data ) { $is_full_refund_without_line_items = true; $parent_order_id = $order->get_parent_id(); $parent_order = wc_get_order( $parent_order_id ); $order_items = $parent_order->get_items(); // Get the partially refunded product and variation IDs along with their sum of product_net_revenue from the parent order. $partial_refund_products = $wpdb->get_results( $wpdb->prepare( " SELECT product_lookup.product_id, product_lookup.variation_id, SUM( product_lookup.product_net_revenue ) AS product_net_revenue FROM %i AS product_lookup INNER JOIN {$wpdb->prefix}wc_order_stats AS order_stats ON order_stats.order_id = product_lookup.order_id WHERE 1 = 1 AND order_stats.parent_id = %d AND product_lookup.product_net_revenue < 0 GROUP BY product_lookup.product_id, product_lookup.variation_id ", $table_name, $parent_order_id ) ); /** * Create a lookup table for partially refunded products. * E.g. [ * '1' => -20, * '2' => -40, * '51' => -10, * '52' => -30, * ] */ foreach ( $partial_refund_products as $product ) { $id = $product->variation_id ? $product->variation_id : $product->product_id; $partial_refund_product_revenue[ $id ] = (float) $product->product_net_revenue; } } foreach ( $order_items as $order_item ) { $order_item_id = $order_item->get_id(); unset( $existing_items[ $order_item_id ] ); $product_qty = $order_item->get_quantity( 'edit' ); $product_id = $order_item->get_product_id( 'edit' ); $variation_id = $order_item->get_variation_id( 'edit' ); $shipping_amount = $order->get_item_shipping_amount( $order_item ); $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item ); $coupon_amount = $order->get_item_coupon_amount( $order_item ); $tax_amount = $order->get_item_cart_tax_amount( $order_item ); $net_revenue = round( $order_item->get_total( 'edit' ), $decimals ); // If the order is a full refund and there is no order items. The order item here is the parent order item. if ( $is_full_refund_without_line_items ) { $id = $variation_id ? $variation_id : $product_id; $partial_refund = $partial_refund_product_revenue[ $id ] ?? 0; // If a single line item was refunded 60% then fully refunded after, we need store the difference in the product lookup table. // E.g. A product costs $100, it was previously partially refunded $60, then fully refunded $40. // So it will be -abs( 100 + (-60) ) = -40. $net_revenue = -abs( $net_revenue + $partial_refund ); // Skip items that have already been fully refunded (single or multiple partial refunds). if ( 0.0 === $net_revenue ) { continue; } $product_qty = -abs( $product_qty ); // Set coupon amount to 0 for full refunds without line items. $coupon_amount = 0; if ( $parent_order ) { $remaining_refund_items = $parent_order->get_remaining_refund_items(); // Calculate the shipping amount to refund from the parent order. $total_shipping_refunded = $parent_order->get_total_shipping_refunded(); $shipping_total = (float) $parent_order->get_shipping_total(); $total_shipping_to_refund = $shipping_total - $total_shipping_refunded; if ( $total_shipping_to_refund > 0 ) { $shipping_amount = -abs( $parent_order->get_item_shipping_amount( $order_item, $remaining_refund_items, $total_shipping_to_refund ) ); } // Calculate the shipping tax amount to refund from the parent order. $shipping_tax = (float) $parent_order->get_shipping_tax(); $total_shipping_tax_refunded = $parent_order->get_total_shipping_tax_refunded(); $total_shipping_tax_to_refund = $shipping_tax - $total_shipping_tax_refunded; if ( $total_shipping_tax_to_refund > 0 ) { $shipping_tax_amount = -abs( $parent_order->get_item_shipping_tax_amount( $order_item, $remaining_refund_items, $total_shipping_tax_to_refund ) ); } // Calculate cart tax amount of the item from the parent order. $tax_amount = -abs( $parent_order->get_item_cart_tax_amount( $order_item ) ); } } $is_refund = $net_revenue < 0; // Skip line items without changes to product quantity. if ( ! $product_qty && ! $is_refund ) { ++$num_updated; continue; } if ( $round_tax ) { $tax_amount = round( $tax_amount, $decimals ); } $result = $wpdb->replace( self::get_db_table_name(), array( 'order_item_id' => $order_item_id, 'order_id' => $order->get_id(), 'product_id' => $product_id, 'variation_id' => $variation_id, 'customer_id' => $order->get_report_customer_id(), 'product_qty' => $product_qty, 'product_net_revenue' => $net_revenue, 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), 'coupon_amount' => $coupon_amount, 'tax_amount' => $tax_amount, 'shipping_amount' => $shipping_amount, 'shipping_tax_amount' => $shipping_tax_amount, // @todo Can this be incorrect if modified by filters? 'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount, ), array( '%d', // order_item_id. '%d', // order_id. '%d', // product_id. '%d', // variation_id. '%d', // customer_id. '%d', // product_qty. '%f', // product_net_revenue. '%s', // date_created. '%f', // coupon_amount. '%f', // tax_amount. '%f', // shipping_amount. '%f', // shipping_tax_amount. '%f', // product_gross_revenue. ) ); // WPCS: cache ok, DB call ok, unprepared SQL ok. /** * Fires when product's reports are updated. * * @param int $order_item_id Order Item ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } if ( ! empty( $existing_items ) ) { $existing_items = array_flip( $existing_items ); $format = array_fill( 0, count( $existing_items ), '%d' ); $format = implode( ',', $format ); array_unshift( $existing_items, $order_id ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})", $existing_items ) ); } return ( count( $order_items ) === $num_updated ); } /** * Clean products data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when product's reports are removed from database. * * @param int $product_id Product ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_product', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', 'product_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'product_id' ); } } Reports/Products/Controller.php 0000777 00000027200 15252174340 0012654 0 ustar 00 <?php /** * REST API Reports products controller * * Handles requests to the /reports/products endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports products controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/products'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'categories' => 'category_includes', 'products' => 'product_includes', 'variations' => 'variation_includes', ); /** * Get data from `'products'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'products' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. * * @since 6.5.0 */ $filtered_response = apply_filters( 'woocommerce_rest_prepare_report_products', $response, $report, $request ); if ( isset( $filtered_response->data['extended_info']['name'] ) ) { $filtered_response->data['extended_info']['name'] = wp_strip_all_tags( $filtered_response->data['extended_info']['name'] ); } return $filtered_response; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $args[ $param_name ] = $request[ $param_name ]; } } } return $args; } /** * Prepare links for the request. * * @param Array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_products', 'type' => 'object', 'properties' => array( 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'items_sold' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of items sold.', 'woocommerce' ), ), 'net_revenue' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ), ), 'orders_count' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of orders product appeared in.', 'woocommerce' ), ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product name.', 'woocommerce' ), ), 'price' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product price.', 'woocommerce' ), ), 'image' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product image.', 'woocommerce' ), ), 'permalink' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product link.', 'woocommerce' ), ), 'category_ids' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product category IDs.', 'woocommerce' ), ), 'stock_status' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory status.', 'woocommerce' ), ), 'stock_quantity' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory quantity.', 'woocommerce' ), ), 'low_stock_amount' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ), ), 'variations' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product variations IDs.', 'woocommerce' ), ), 'sku' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product SKU.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'items_sold', 'product_name', 'variations', 'sku', ) ); $params['categories'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each product to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get stock status column export value. * * @param array $status Stock status from report row. * @return string */ protected function get_stock_status( $status ) { $statuses = wc_get_product_stock_status_options(); return isset( $statuses[ $status ] ) ? $statuses[ $status ] : ''; } /** * Get categories column export value. * * @param array $category_ids Category IDs from report row. * @return string */ protected function get_categories( $category_ids ) { $category_names = get_terms( array( 'taxonomy' => 'product_cat', 'include' => $category_ids, 'fields' => 'names', ) ); return implode( ', ', $category_names ); } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'product_name' => __( 'Product title', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'N. Revenue', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'product_cat' => __( 'Category', 'woocommerce' ), 'variations' => __( 'Variations', 'woocommerce' ), ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_columns['stock_status'] = __( 'Status', 'woocommerce' ); $export_columns['stock'] = __( 'Stock', 'woocommerce' ); } /** * Filter to add or remove column names from the products report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_products_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'product_name' => $item['extended_info']['name'], 'sku' => $item['extended_info']['sku'], 'items_sold' => $item['items_sold'], 'net_revenue' => $item['net_revenue'], 'orders_count' => $item['orders_count'], 'product_cat' => $this->get_categories( $item['extended_info']['category_ids'] ), 'variations' => isset( $item['extended_info']['variations'] ) ? count( $item['extended_info']['variations'] ) : 0, ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { if ( $item['extended_info']['manage_stock'] ) { $export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] ); $export_item['stock'] = $item['extended_info']['stock_quantity']; } else { $export_item['stock_status'] = __( 'N/A', 'woocommerce' ); $export_item['stock'] = __( 'N/A', 'woocommerce' ); } } /** * Filter to prepare extra columns in the export item for the products * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_products_prepare_export_item', $export_item, $item ); } } Reports/Products/Query.php 0000777 00000003623 15252174340 0011641 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Query * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_products_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_products_select_query', $results, $args ); } } Reports/GenericQuery.php 0000777 00000005113 15252174340 0011327 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use WC_Data_Store; /** * A generic class for a report-specific query to be used in Analytics. * * Example usage: * <pre><code class="language-php">$args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * ); * $report = new GenericQuery( $args, 'coupons' ); * $mydata = $report->get_data(); * </code></pre> * * It uses the name provided in the class property or in the constructor call to load the `report-{name}` data store. * * It's used by the {@see GenericController GenericController}. * * @since 9.3.0 */ class GenericQuery extends \WC_Object_Query { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name; /** * Create a new query. * * @param array $args Criteria to query on in a format similar to WP_Query. * @param string $name Query name. * @extends WC_Object_Query::_construct */ public function __construct( $args, $name = null ) { $this->name = $name ?? $this->name; return parent::__construct( $args ); // phpcs:ignore Universal.CodeAnalysis.ConstructorDestructorReturn.ReturnValueFound } /** * Valid fields for Products report. * * @return array */ protected function get_default_query_vars() { return array(); } /** * Get data from `report-{$name}` store, based on the current query vars. * Filters query vars through `woocommerce_analytics_{snake_case(name)}_query_args` filter. * Filters results through `woocommerce_analytics_{snake_case(name)}_select_query` filter. * * @return mixed filtered results from the data store. */ public function get_data() { $snake_name = str_replace( '-', '_', $this->name ); /** * Filter query args given for the report. * * @since 9.3.0 * * @param array $query_args Query args. */ $args = apply_filters( "woocommerce_analytics_{$snake_name}_query_args", $this->get_query_vars() ); $data_store = \WC_Data_Store::load( "report-{$this->name}" ); $results = $data_store->get_data( $args ); /** * Filter report query results. * * @since 9.3.0 * * @param stdClass|WP_Error $results Results from the data store. * @param array $args Query args used to get the data (potentially filtered). */ return apply_filters( "woocommerce_analytics_{$snake_name}_select_query", $results, $args ); } } Reports/DataStore.php 0000777 00000155525 15252174340 0010630 0 ustar 00 <?php /** * Admin\API\Reports\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * Common parent for custom report data stores. * * We use Report DataStores to separate DB data retrieval logic from the REST API controllers. * * Handles caching, data normalization, intervals-related methods, and other common functionality. * So, in your custom report DataStore class that extends this class * you can focus on specifics by overriding the `get_noncached_data` method. * * Minimalistic example: * <pre><code class="language-php">class MyDataStore extends DataStore implements DataStoreInterface { * /** Cache identifier, used by the `DataStore` class to handle caching for you. */ * protected $cache_key = 'my_thing'; * /** Data store context used to pass to filters. */ * protected $context = 'my_thing'; * /** Table used to get the data. */ * protected static $table_name = 'my_table'; * /** * * Method that overrides the `DataStore::get_noncached_data()` to return the report data. * * Will be called by `get_data` if there is no data in cache. * */ * public function get_noncached_data( $query ) { * // Do your magic. * * // Then return your data in conforming object structure. * return (object) array( * 'data' => $product_data, * 'total' => 1, * 'page_no' => 1, * 'pages' => 1, * ); * } * } * </code></pre> * * Please use the `woocommerce_data_stores` filter to add your custom data store to the list of available ones. * Then, your store could be accessed by Controller classes ({@see GenericController::get_datastore_data() GenericController::get_datastore_data()}) * or using {@link \WC_Data_Store::load() \WC_Data_Store::load()}. * * We recommend registering using the REST base name of your Controller as the key, e.g.: * <pre><code class="language-php">add_filter( 'woocommerce_data_stores', function( $stores ) { * $stores['reports/my-thing'] = 'MyExtension\Admin\Analytics\Rest_API\MyDataStore'; * } ); * </code></pre> * This way, `GenericController` will pick it up automatically. * * Note that this class is NOT {@link https://developer.woocommerce.com/docs/how-to-manage-woocommerce-data-stores/ a CRUD data store}. * It does not implement the {@see WC_Object_Data_Store_Interface WC_Object_Data_Store_Interface} nor extend WC_Data & WC_Data_Store_WP classes. */ class DataStore extends SqlQuery implements DataStoreInterface { /** * Cache group for the reports. * * @var string */ protected $cache_group = 'reports'; /** * Time out for the cache. * * @var int */ protected $cache_timeout = 3600; /** * Cache identifier. * * @var string */ protected $cache_key = ''; /** * Table used as a data store for this report. * * @var string */ protected static $table_name = ''; /** * Date field name. * * @var string */ protected $date_column_name = 'date_created'; /** * Mapping columns to data type to return correct response types. * * @var array */ protected $column_types = array(); /** * SQL columns to select in the db query. * * @var array */ protected $report_columns = array(); // @todo This does not really belong here, maybe factor out the comparison as separate class? /** * Order by property, used in the cmp function. * * @var string */ private $order_by = ''; /** * Order property, used in the cmp function. * * @var string */ private $order = ''; /** * Query limit parameters. * * @var array */ private $limit_parameters = array(); /** * Data store context used to pass to filters. * * @override SqlQuery * * @var string */ protected $context = 'reports'; /** * Subquery object for query nesting. * * @var SqlQuery */ protected $subquery; /** * Totals query object. * * @var SqlQuery */ protected $total_query; /** * Intervals query object. * * @var SqlQuery */ protected $interval_query; /** * Refresh the cache for the current query when true. * * @var bool */ protected $force_cache_refresh = false; /** * Include debugging information in the returned data when true. * * @var bool */ protected $debug_cache = true; /** * Debugging information to include in the returned data. * * @var array */ protected $debug_cache_data = array(); /** * Class constructor. * * @override SqlQuery::__construct() */ public function __construct() { self::set_db_table_name(); $this->assign_report_columns(); if ( $this->report_columns ) { $this->report_columns = apply_filters( 'woocommerce_admin_report_columns', $this->report_columns, $this->context, self::get_db_table_name() ); } // Utilize enveloped responses to include debugging info. // See https://querymonitor.com/blog/2021/05/debugging-wordpress-rest-api-requests/ if ( isset( $_GET['_envelope'] ) ) { $this->debug_cache = true; add_filter( 'rest_envelope_response', array( $this, 'add_debug_cache_to_envelope' ), 999, 2 ); } } /** * Get the data based on args. * * Returns the report data based on parameters supplied by the user. * Fetches it from cache or returns `get_noncached_data` result. * * @param array $query_args Query parameters. * @return stdClass|WP_Error */ public function get_data( $query_args ) { $defaults = $this->get_default_query_vars(); $query_args = wp_parse_args( $query_args, $defaults ); $this->normalize_timezones( $query_args, $defaults ); /* * We need to get the cache key here because * parent::update_intervals_sql_params() modifies $query_args. */ $cache_key = $this->get_cache_key( $query_args ); $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { $data = $this->get_noncached_data( $query_args ); $this->set_cached_data( $cache_key, $data ); } return $data; } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @return array Query parameters. */ public function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), 'page' => 1, 'order' => 'DESC', 'orderby' => 'date', 'before' => TimeInterval::default_before(), 'after' => TimeInterval::default_after(), 'fields' => '*', ); } /** * Get table name from database class. */ public static function get_db_table_name() { global $wpdb; return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { /* translators: %s: Method name */ return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); } /** * Set table name from database class. */ protected static function set_db_table_name() { global $wpdb; if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) { $wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name; } } /** * Whether or not the report should use the caching layer. * * Provides an opportunity for plugins to prevent reports from using cache. * * @return boolean Whether or not to utilize caching. */ protected function should_use_cache() { /** * Determines if a report will utilize caching. * * @param bool $use_cache Whether or not to use cache. * @param string $cache_key The report's cache key. Used to identify the report. */ return (bool) apply_filters( 'woocommerce_analytics_report_should_use_cache', true, $this->cache_key ); } /** * Returns string to be used as cache key for the data. * * @param array $params Query parameters. * @return string */ protected function get_cache_key( $params ) { if ( isset( $params['force_cache_refresh'] ) ) { if ( true === $params['force_cache_refresh'] ) { $this->force_cache_refresh = true; } // We don't want this param in the key. unset( $params['force_cache_refresh'] ); } if ( true === $this->debug_cache ) { $this->debug_cache_data['query_args'] = $params; } // Normalize the $params to reduce cache misses. $params = array_filter( $params, function ( $param ) { return ! empty( $param ); } ); ksort( $params ); return implode( '_', array( 'wc_report', $this->cache_key, md5( wp_json_encode( $params ) ), ) ); } /** * Wrapper around Cache::get(). * * @param string $cache_key Cache key. * @return mixed */ protected function get_cached_data( $cache_key ) { if ( true === $this->debug_cache ) { $this->debug_cache_data['should_use_cache'] = $this->should_use_cache(); $this->debug_cache_data['force_cache_refresh'] = $this->force_cache_refresh; $this->debug_cache_data['cache_hit'] = false; } if ( $this->should_use_cache() && false === $this->force_cache_refresh ) { $cached_data = Cache::get( $cache_key ); $cache_hit = false !== $cached_data; if ( true === $this->debug_cache ) { $this->debug_cache_data['cache_hit'] = $cache_hit; } return $cached_data; } // Cached item has now functionally been refreshed. Reset the option. $this->force_cache_refresh = false; return false; } /** * Wrapper around Cache::set(). * * @param string $cache_key Cache key. * @param mixed $value New value. * @return bool */ protected function set_cached_data( $cache_key, $value ) { if ( $this->should_use_cache() ) { return Cache::set( $cache_key, $value ); } return true; } /** * Add cache debugging information to an enveloped API response. * * @param array $envelope * @param \WP_REST_Response $response * * @return array */ public function add_debug_cache_to_envelope( $envelope, $response ) { if ( 0 !== strncmp( '/wc-analytics', $response->get_matched_route(), 13 ) ) { return $envelope; } if ( ! empty( $this->debug_cache_data ) ) { $envelope['debug_cache'] = $this->debug_cache_data; } return $envelope; } /** * Compares two report data objects by pre-defined object property and ASC/DESC ordering. * * @param stdClass $a Object a. * @param stdClass $b Object b. * @return string */ private function interval_cmp( $a, $b ) { if ( '' === $this->order_by || '' === $this->order ) { return 0; // @todo Should return WP_Error here perhaps? } if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) { // As relative order is undefined in case of equality in usort, second-level sorting by date needs to be enforced // so that paging is stable. if ( $a['time_interval'] === $b['time_interval'] ) { return 0; // This should never happen. } elseif ( $a['time_interval'] > $b['time_interval'] ) { return 1; } elseif ( $a['time_interval'] < $b['time_interval'] ) { return -1; } } elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) { return strtolower( $this->order ) === 'desc' ? -1 : 1; } elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) { return strtolower( $this->order ) === 'desc' ? 1 : -1; } } /** * Sorts intervals according to user's request. * * They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones. * * @param stdClass $data Data object, must contain an array under $data->intervals. * @param string $sort_by Ordering property. * @param string $direction DESC/ASC. */ protected function sort_intervals( &$data, $sort_by, $direction ) { $this->sort_array( $data->intervals, $sort_by, $direction ); } /** * Sorts array of arrays based on subarray key $sort_by. * * @param array $arr Array to sort. * @param string $sort_by Ordering property. * @param string $direction DESC/ASC. */ protected function sort_array( &$arr, $sort_by, $direction ) { $this->order_by = $this->normalize_order_by( $sort_by ); $this->order = $direction; usort( $arr, array( $this, 'interval_cmp' ) ); } /** * Fills in interval gaps from DB with 0-filled objects. * * @param array $db_intervals Array of all intervals present in the db. * @param DateTime $start_datetime Start date. * @param DateTime $end_datetime End date. * @param string $time_interval Time interval, e.g. day, week, month. * @param stdClass $data Data with SQL extracted intervals. * @return stdClass */ protected function fill_in_missing_intervals( $db_intervals, $start_datetime, $end_datetime, $time_interval, &$data ) { // @todo This is ugly and messy. $local_tz = new \DateTimeZone( wc_timezone_string() ); // At this point, we don't know when we can stop iterating, as the ordering can be based on any value. $time_ids = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) ); $db_intervals = array_flip( $db_intervals ); // Totals object used to get all needed properties. $totals_arr = get_object_vars( $data->totals ); foreach ( $totals_arr as $key => $val ) { $totals_arr[ $key ] = 0; } // @todo Should 'products' be in intervals? unset( $totals_arr['products'] ); while ( $start_datetime <= $end_datetime ) { $next_start = TimeInterval::iterate( $start_datetime, $time_interval ); $time_id = TimeInterval::time_interval_id( $time_interval, $start_datetime ); // Either create fill-zero interval or use data from db. if ( $next_start > $end_datetime ) { $interval_end = $end_datetime->format( 'Y-m-d H:i:s' ); } else { $prev_end_timestamp = (int) $next_start->format( 'U' ) - 1; $prev_end = new \DateTime(); $prev_end->setTimestamp( $prev_end_timestamp ); $prev_end->setTimezone( $local_tz ); $interval_end = $prev_end->format( 'Y-m-d H:i:s' ); } if ( array_key_exists( $time_id, $time_ids ) ) { // For interval present in the db for this time frame, just fill in dates. $record = &$data->intervals[ $time_ids[ $time_id ] ]; $record['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' ); $record['date_end'] = $interval_end; } elseif ( ! array_key_exists( $time_id, $db_intervals ) ) { // For intervals present in the db outside of this time frame, do nothing. // For intervals not present in the db, fabricate it. $record_arr = array(); $record_arr['time_interval'] = $time_id; $record_arr['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' ); $record_arr['date_end'] = $interval_end; $data->intervals[] = array_merge( $record_arr, $totals_arr ); } $start_datetime = $next_start; } return $data; } /** * Converts input datetime parameters to local timezone. If there are no inputs from the user in query_args, * uses default from $defaults. * * @param array $query_args Array of query arguments. * @param array $defaults Array of default values. */ protected function normalize_timezones( &$query_args, $defaults ) { $local_tz = new \DateTimeZone( wc_timezone_string() ); foreach ( array( 'before', 'after' ) as $query_arg_key ) { if ( isset( $query_args[ $query_arg_key ] ) && is_string( $query_args[ $query_arg_key ] ) ) { // Assume that unspecified timezone is a local timezone. $datetime = new \DateTime( $query_args[ $query_arg_key ], $local_tz ); // In case timezone was forced by using +HH:MM, convert to local timezone. $datetime->setTimezone( $local_tz ); $query_args[ $query_arg_key ] = $datetime; } elseif ( isset( $query_args[ $query_arg_key ] ) && is_a( $query_args[ $query_arg_key ], 'DateTime' ) ) { // In case timezone is in other timezone, convert to local timezone. $query_args[ $query_arg_key ]->setTimezone( $local_tz ); } else { $query_args[ $query_arg_key ] = isset( $defaults[ $query_arg_key ] ) ? $defaults[ $query_arg_key ] : null; } } } /** * Removes extra records from intervals so that only requested number of records get returned. * * @param stdClass $data Data from whose intervals the records get removed. * @param int $page_no Offset requested by the user. * @param int $items_per_page Number of records requested by the user. * @param int $db_interval_count Database interval count. * @param int $expected_interval_count Expected interval count on the output. * @param string $order_by Order by field. * @param string $order ASC or DESC. */ protected function remove_extra_records( &$data, $page_no, $items_per_page, $db_interval_count, $expected_interval_count, $order_by, $order ) { if ( 'date' === strtolower( $order_by ) ) { $offset = 0; } else { if ( 'asc' === strtolower( $order ) ) { $offset = ( $page_no - 1 ) * $items_per_page; } else { $offset = ( $page_no - 1 ) * $items_per_page - $db_interval_count; } $offset = $offset < 0 ? 0 : $offset; } $count = $expected_interval_count - ( $page_no - 1 ) * $items_per_page; if ( $count < 0 ) { $count = 0; } elseif ( $count > $items_per_page ) { $count = $items_per_page; } $data->intervals = array_slice( $data->intervals, $offset, $count ); } /** * Returns expected number of items on the page in case of date ordering. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * * @return float|int */ protected function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) { $total_pages = (int) ceil( $expected_interval_count / $items_per_page ); if ( $page_no < $total_pages ) { return $items_per_page; } elseif ( $page_no === $total_pages ) { return $expected_interval_count - ( $page_no - 1 ) * $items_per_page; } else { return 0; } } /** * Returns true if there are any intervals that need to be filled in the response. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $db_records Total number of records for given period in the database. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * @param string $order asc or desc. * @param string $order_by Column by which the result will be sorted. * @param int $intervals_count Number of records for given (possibly shortened) time interval. * * @return bool */ protected function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) { if ( $expected_interval_count <= $db_records ) { return false; } if ( 'date' === $order_by ) { $expected_intervals_on_page = $this->expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ); return $intervals_count < $expected_intervals_on_page; } if ( 'desc' === $order ) { return $page_no > floor( $db_records / $items_per_page ); } if ( 'asc' === $order ) { return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page ); } // Invalid ordering. return false; } /** * Updates the LIMIT query part for Intervals query of the report. * * If there are less records in the database than time intervals, then we need to remap offset in SQL query * to fetch correct records. * * @param array $query_args Query arguments. * @param int $db_interval_count Database interval count. * @param int $expected_interval_count Expected interval count on the output. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function update_intervals_sql_params( &$query_args, $db_interval_count, $expected_interval_count, $table_name ) { if ( $db_interval_count === $expected_interval_count ) { return; } $params = $this->get_limit_params( $query_args ); $local_tz = new \DateTimeZone( wc_timezone_string() ); if ( 'date' === strtolower( $query_args['orderby'] ) ) { // page X in request translates to slightly different dates in the db, in case some // records are missing from the db. $start_iteration = 0; $end_iteration = 0; if ( 'asc' === strtolower( $query_args['order'] ) ) { // ORDER BY date ASC. $new_start_date = $query_args['after']; $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $latest_end_date = $query_args['before']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_start_date > $latest_end_date ) { $new_start_date = $latest_end_date; $start_iteration = 0; break; } $new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'] ); $start_iteration ++; } $new_end_date = clone $new_start_date; for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_end_date > $latest_end_date ) { break; } $new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'] ); $end_iteration ++; } if ( $new_end_date > $latest_end_date ) { $new_end_date = $latest_end_date; $end_iteration = 0; } if ( $end_iteration ) { $new_end_date_timestamp = (int) $new_end_date->format( 'U' ) - 1; $new_end_date->setTimestamp( $new_end_date_timestamp ); } } else { // ORDER BY date DESC. $new_end_date = $query_args['before']; $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $earliest_start_date = $query_args['after']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_end_date < $earliest_start_date ) { $new_end_date = $earliest_start_date; $end_iteration = 0; break; } $new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'], true ); $end_iteration ++; } $new_start_date = clone $new_end_date; for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_start_date < $earliest_start_date ) { break; } $new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'], true ); $start_iteration ++; } if ( $new_start_date < $earliest_start_date ) { $new_start_date = $earliest_start_date; $start_iteration = 0; } if ( $start_iteration ) { // @todo Is this correct? should it only be added if iterate runs? other two iterate instances, too? $new_start_date_timestamp = (int) $new_start_date->format( 'U' ) + 1; $new_start_date->setTimestamp( $new_start_date_timestamp ); } } // @todo - Do this without modifying $query_args? $query_args['adj_after'] = $new_start_date; $query_args['adj_before'] = $new_end_date; $adj_after = $new_start_date->format( TimeInterval::$sql_datetime_format ); $adj_before = $new_end_date->format( TimeInterval::$sql_datetime_format ); $this->interval_query->clear_sql_clause( array( 'where_time', 'limit' ) ); $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$adj_before'" ); $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$adj_after'" ); $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', 'LIMIT 0,' . $params['per_page'] ); } else { if ( 'asc' === $query_args['order'] ) { $offset = ( ( $query_args['page'] - 1 ) * $params['per_page'] ) - ( $expected_interval_count - $db_interval_count ); $offset = $offset < 0 ? 0 : $offset; $count = $query_args['page'] * $params['per_page'] - ( $expected_interval_count - $db_interval_count ); if ( $count < 0 ) { $count = 0; } elseif ( $count > $params['per_page'] ) { $count = $params['per_page']; } $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', 'LIMIT ' . $offset . ',' . $count ); } // Otherwise no change in limit clause. // @todo - Do this without modifying $query_args? $query_args['adj_after'] = $query_args['after']; $query_args['adj_before'] = $query_args['before']; } } /** * Casts strings returned from the database to appropriate data types for output. * * @param array $array Associative array of values extracted from the database. * @return array|WP_Error */ protected function cast_numbers( $array ) { $retyped_array = array(); $column_types = apply_filters( 'woocommerce_rest_reports_column_types', $this->column_types, $array ); foreach ( $array as $column_name => $value ) { if ( is_array( $value ) ) { $value = $this->cast_numbers( $value ); } if ( isset( $column_types[ $column_name ] ) ) { $retyped_array[ $column_name ] = $column_types[ $column_name ]( $value ); } else { $retyped_array[ $column_name ] = $value; } } return $retyped_array; } /** * Returns a list of columns selected by the query_args formatted as a comma separated string. * * @param array $query_args User-supplied options. * @return string */ protected function selected_columns( $query_args ) { $selections = $this->report_columns; if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) { $keep = array(); foreach ( $query_args['fields'] as $field ) { if ( isset( $selections[ $field ] ) ) { $keep[ $field ] = $selections[ $field ]; } } $selections = implode( ', ', $keep ); } else { $selections = implode( ', ', $selections ); } return $selections; } /** * Get the excluded order statuses used when calculating reports. * * @return array */ protected static function get_excluded_report_order_statuses() { $excluded_statuses = \WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) ); $excluded_statuses = array_merge( array( 'auto-draft', 'trash' ), array_map( 'esc_sql', $excluded_statuses ) ); return apply_filters( 'woocommerce_analytics_excluded_order_statuses', $excluded_statuses ); } /** * Maps order status provided by the user to the one used in the database. * * @param string $status Order status. * @return string */ protected static function normalize_order_status( $status ) { $status = trim( $status ); return 'wc-' . $status; } /** * Normalizes order_by clause to match to SQL query. * * @param string $order_by Order by option requested by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } /** * Updates start and end dates for intervals so that they represent intervals' borders, not times when data in db were recorded. * * E.g. if there are db records for only Tuesday and Thursday this week, the actual week interval is [Mon, Sun], not [Tue, Thu]. * * @param DateTime $start_datetime Start date. * @param DateTime $end_datetime End date. * @param string $time_interval Time interval, e.g. day, week, month. * @param array $intervals Array of intervals extracted from SQL db. */ protected function update_interval_boundary_dates( $start_datetime, $end_datetime, $time_interval, &$intervals ) { $local_tz = new \DateTimeZone( wc_timezone_string() ); foreach ( $intervals as $key => $interval ) { $datetime = new \DateTime( $interval['datetime_anchor'], $local_tz ); $prev_start = TimeInterval::iterate( $datetime, $time_interval, true ); // @todo Not sure if the +1/-1 here are correct, especially as they are applied before the ?: below. $prev_start_timestamp = (int) $prev_start->format( 'U' ) + 1; $prev_start->setTimestamp( $prev_start_timestamp ); if ( $start_datetime ) { $date_start = $prev_start < $start_datetime ? $start_datetime : $prev_start; $intervals[ $key ]['date_start'] = $date_start->format( 'Y-m-d H:i:s' ); } else { $intervals[ $key ]['date_start'] = $prev_start->format( 'Y-m-d H:i:s' ); } $next_end = TimeInterval::iterate( $datetime, $time_interval ); $next_end_timestamp = (int) $next_end->format( 'U' ) - 1; $next_end->setTimestamp( $next_end_timestamp ); if ( $end_datetime ) { $date_end = $next_end > $end_datetime ? $end_datetime : $next_end; $intervals[ $key ]['date_end'] = $date_end->format( 'Y-m-d H:i:s' ); } else { $intervals[ $key ]['date_end'] = $next_end->format( 'Y-m-d H:i:s' ); } $intervals[ $key ]['interval'] = $time_interval; } } /** * Change structure of intervals to form a correct response. * * Also converts local datetimes to GMT and adds them to the intervals. * * @param array $intervals Time interval, e.g. day, week, month. */ protected function create_interval_subtotals( &$intervals ) { foreach ( $intervals as $key => $interval ) { $start_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_start'] ); $end_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_end'] ); // Move intervals result to subtotals object. $intervals[ $key ] = array( 'interval' => $interval['time_interval'], 'date_start' => $interval['date_start'], 'date_start_gmt' => $start_gmt->format( TimeInterval::$sql_datetime_format ), 'date_end' => $interval['date_end'], 'date_end_gmt' => $end_gmt->format( TimeInterval::$sql_datetime_format ), ); unset( $interval['interval'] ); unset( $interval['date_start'] ); unset( $interval['date_end'] ); unset( $interval['datetime_anchor'] ); unset( $interval['time_interval'] ); $intervals[ $key ]['subtotals'] = (object) $this->cast_numbers( $interval ); } } /** * Fills WHERE clause of SQL request with date-related constraints. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_time_period_sql_params( $query_args, $table_name ) { $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); if ( isset( $this->subquery ) ) { $this->subquery->clear_sql_clause( 'where_time' ); } if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) { if ( is_a( $query_args['before'], 'WC_DateTime' ) ) { $datetime_str = $query_args['before']->date( TimeInterval::$sql_datetime_format ); } else { $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" ); } else { $this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" ); } } if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) { if ( is_a( $query_args['after'], 'WC_DateTime' ) ) { $datetime_str = $query_args['after']->date( TimeInterval::$sql_datetime_format ); } else { $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" ); } else { $this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" ); } } } /** * Fills LIMIT clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_limit_sql_params( $query_args ) { global $wpdb; $params = $this->get_limit_params( $query_args ); $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', $wpdb->prepare( 'LIMIT %d, %d', $params['offset'], $params['per_page'] ) ); return $params; } /** * Fills LIMIT parameters of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_limit_params( $query_args = array() ) { if ( isset( $query_args['per_page'] ) && is_numeric( $query_args['per_page'] ) ) { $this->limit_parameters['per_page'] = (int) $query_args['per_page']; } else { $this->limit_parameters['per_page'] = get_option( 'posts_per_page' ); } $this->limit_parameters['offset'] = 0; if ( isset( $query_args['page'] ) ) { $this->limit_parameters['offset'] = ( (int) $query_args['page'] - 1 ) * $this->limit_parameters['per_page']; } return $this->limit_parameters; } /** * Generates a virtual table given a list of IDs. * * @param array $ids Array of IDs. * @param array $id_field Name of the ID field. * @param array $other_values Other values that must be contained in the virtual table. * @return array */ protected function get_ids_table( $ids, $id_field, $other_values = array() ) { global $wpdb; $selects = array(); foreach ( $ids as $id ) { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $new_select = $wpdb->prepare( "SELECT %s AS {$id_field}", $id ); foreach ( $other_values as $key => $value ) { $new_select .= $wpdb->prepare( ", %s AS {$key}", $value ); } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_push( $selects, $new_select ); } return join( ' UNION ', $selects ); } /** * Returns a comma separated list of the fields in the `query_args`, if there aren't, returns `report_columns` keys. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_fields( $query_args ) { if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) { return $query_args['fields']; } return array_keys( $this->report_columns ); } /** * Returns a comma separated list of the field names prepared to be used for a selection after a join with `default_results`. * * @param array $fields Array of fields name. * @param array $default_results_fields Fields to load from `default_results` table. * @param array $outer_selections Array of fields that are not selected in the inner query. * @return string */ protected function format_join_selections( $fields, $default_results_fields, $outer_selections = array() ) { foreach ( $fields as $i => $field ) { foreach ( $default_results_fields as $default_results_field ) { if ( $field === $default_results_field ) { $field = esc_sql( $field ); $fields[ $i ] = "default_results.{$field} AS {$field}"; } } if ( in_array( $field, $outer_selections, true ) && array_key_exists( $field, $this->report_columns ) ) { $fields[ $i ] = $this->report_columns[ $field ]; } } return implode( ', ', $fields ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. */ protected function add_order_by_sql_params( $query_args ) { if ( isset( $query_args['orderby'] ) ) { $order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); } else { $order_by_clause = ''; } $this->clear_sql_clause( 'order_by' ); $this->add_sql_clause( 'order_by', $order_by_clause ); $this->add_orderby_order_clause( $query_args, $this ); } /** * Fills FROM and WHERE clauses of SQL request for 'Intervals' section of data response based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_intervals_sql_params( $query_args, $table_name ) { $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); $this->add_time_period_sql_params( $query_args, $table_name ); if ( isset( $query_args['interval'] ) && '' !== $query_args['interval'] ) { $interval = $query_args['interval']; $this->clear_sql_clause( 'select' ); $this->add_sql_clause( 'select', TimeInterval::db_datetime_format( $interval, $table_name, $this->date_column_name ) ); } } /** * Get join and where clauses for refunds based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_refund_subquery( $query_args ) { global $wpdb; $table_name = $wpdb->prefix . 'wc_order_stats'; $sql_query = array( 'where_clause' => '', 'from_clause' => '', ); if ( ! isset( $query_args['refunds'] ) ) { return $sql_query; } if ( 'all' === $query_args['refunds'] ) { $sql_query['where_clause'] .= 'parent_id != 0'; } if ( 'none' === $query_args['refunds'] ) { $sql_query['where_clause'] .= 'parent_id = 0'; } if ( 'full' === $query_args['refunds'] || 'partial' === $query_args['refunds'] ) { $operator = 'full' === $query_args['refunds'] ? '=' : '!='; $sql_query['from_clause'] .= " JOIN {$table_name} parent_order_stats ON {$table_name}.parent_id = parent_order_stats.order_id"; $sql_query['where_clause'] .= "parent_order_stats.status {$operator} '{$this->normalize_order_status( 'refunded' )}'"; } return $sql_query; } /** * Returns an array of products belonging to given categories. * * @param array $categories List of categories IDs. * @return array|stdClass */ protected function get_products_by_cat_ids( $categories ) { $terms = get_terms( array( 'taxonomy' => 'product_cat', 'include' => $categories, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } $args = array( 'category' => wc_list_pluck( $terms, 'slug' ), 'limit' => -1, 'return' => 'ids', ); return wc_get_products( $args ); } /** * Get WHERE filter by object ids subquery. * * @param string $select_table Select table name. * @param string $select_field Select table object ID field name. * @param string $filter_table Lookup table name. * @param string $filter_field Lookup table object ID field name. * @param string $compare Comparison string (IN|NOT IN). * @param string $id_list Comma separated ID list. * * @return string */ protected function get_object_where_filter( $select_table, $select_field, $filter_table, $filter_field, $compare, $id_list ) { global $wpdb; if ( empty( $id_list ) ) { return ''; } $lookup_name = isset( $wpdb->$filter_table ) ? $wpdb->$filter_table : $wpdb->prefix . $filter_table; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return " {$select_table}.{$select_field} {$compare} ( SELECT DISTINCT {$filter_table}.{$select_field} FROM {$filter_table} WHERE {$filter_table}.{$filter_field} IN ({$id_list}) )"; // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * Returns an array of ids of allowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_products_array( $query_args ) { $included_products = array(); $operator = $this->get_match_operator( $query_args ); if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) { $included_products = $this->get_products_by_cat_ids( $query_args['category_includes'] ); // If no products were found in the specified categories, we will force an empty set // by matching a product ID of -1, unless the filters are OR/any and products are specified. if ( empty( $included_products ) ) { $included_products = array( '-1' ); } } if ( isset( $query_args['product_includes'] ) && is_array( $query_args['product_includes'] ) && count( $query_args['product_includes'] ) > 0 ) { if ( count( $included_products ) > 0 ) { if ( 'AND' === $operator ) { // AND results in an intersection between products from selected categories and manually included products. $included_products = array_intersect( $included_products, $query_args['product_includes'] ); } elseif ( 'OR' === $operator ) { // OR results in a union of products from selected categories and manually included products. $included_products = array_merge( $included_products, $query_args['product_includes'] ); } } else { $included_products = $query_args['product_includes']; } } return $included_products; } /** * Returns comma separated ids of allowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_products( $query_args ) { $included_products = $this->get_included_products_array( $query_args ); return implode( ',', $included_products ); } /** * Returns comma separated ids of allowed variations, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_variations( $query_args ) { return $this->get_filtered_ids( $query_args, 'variation_includes' ); } /** * Returns comma separated ids of excluded variations, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_variations( $query_args ) { return $this->get_filtered_ids( $query_args, 'variation_excludes' ); } /** * Returns an array of ids of disallowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_excluded_products_array( $query_args ) { $excluded_products = array(); $operator = $this->get_match_operator( $query_args ); if ( isset( $query_args['category_excludes'] ) && is_array( $query_args['category_excludes'] ) && count( $query_args['category_excludes'] ) > 0 ) { $excluded_products = $this->get_products_by_cat_ids( $query_args['category_excludes'] ); } if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) { $excluded_products = array_merge( $excluded_products, $query_args['product_excludes'] ); } return $excluded_products; } /** * Returns comma separated ids of excluded products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_products( $query_args ) { $excluded_products = $this->get_excluded_products_array( $query_args ); return implode( ',', $excluded_products ); } /** * Returns comma separated ids of included categories, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_categories( $query_args ) { return $this->get_filtered_ids( $query_args, 'category_includes' ); } /** * Returns comma separated ids of included coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Field name in the parameter list. * @return string */ protected function get_included_coupons( $query_args, $field = 'coupon_includes' ) { return $this->get_filtered_ids( $query_args, $field ); } /** * Returns comma separated ids of excluded coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_coupons( $query_args ) { return $this->get_filtered_ids( $query_args, 'coupon_excludes' ); } /** * Returns comma separated ids of included orders, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_orders( $query_args ) { return $this->get_filtered_ids( $query_args, 'order_includes' ); } /** * Returns comma separated ids of excluded orders, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_orders( $query_args ) { return $this->get_filtered_ids( $query_args, 'order_excludes' ); } /** * Returns comma separated ids of included users, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_users( $query_args ) { return $this->get_filtered_ids( $query_args, 'user_includes' ); } /** * Returns comma separated ids of excluded users, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_users( $query_args ) { return $this->get_filtered_ids( $query_args, 'user_excludes' ); } /** * Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $operator AND or OR, based on match query argument. * @return string */ protected function get_status_subquery( $query_args, $operator = 'AND' ) { global $wpdb; $subqueries = array(); $excluded_statuses = array(); if ( isset( $query_args['status_is'] ) && is_array( $query_args['status_is'] ) && count( $query_args['status_is'] ) > 0 ) { $allowed_statuses = array_map( array( $this, 'normalize_order_status' ), esc_sql( $query_args['status_is'] ) ); if ( $allowed_statuses ) { $subqueries[] = "{$wpdb->prefix}wc_order_stats.status IN ( '" . implode( "','", $allowed_statuses ) . "' )"; } } if ( isset( $query_args['status_is_not'] ) && is_array( $query_args['status_is_not'] ) && count( $query_args['status_is_not'] ) > 0 ) { $excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $query_args['status_is_not'] ); } if ( ( ! isset( $query_args['status_is'] ) || empty( $query_args['status_is'] ) ) && ( ! isset( $query_args['status_is_not'] ) || empty( $query_args['status_is_not'] ) ) ) { $excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $this->get_excluded_report_order_statuses() ); } if ( $excluded_statuses ) { $subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", $excluded_statuses ) . "' )"; } return implode( " $operator ", $subqueries ); } /** * Add order status SQL clauses if included in query. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Database table name. * @param SqlQuery $sql_query Query object. */ protected function add_order_status_clause( $query_args, $table_name, &$sql_query ) { global $wpdb; $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $sql_query->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); $sql_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Add order by SQL clause if included in query. * * @param array $query_args Parameters supplied by the user. * @param SqlQuery $sql_query Query object. * @return string Order by clause. */ protected function add_order_by_clause( $query_args, &$sql_query ) { $order_by_clause = ''; $sql_query->clear_sql_clause( array( 'order_by' ) ); if ( isset( $query_args['orderby'] ) ) { $order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); $sql_query->add_sql_clause( 'order_by', $order_by_clause ); } // Return ORDER BY clause to allow adding the sort field(s) to query via a JOIN. return $order_by_clause; } /** * Add order by order SQL clause. * * @param array $query_args Parameters supplied by the user. * @param SqlQuery $sql_query Query object. */ protected function add_orderby_order_clause( $query_args, &$sql_query ) { if ( isset( $query_args['order'] ) ) { $sql_query->add_sql_clause( 'order_by', esc_sql( $query_args['order'] ) ); } else { $sql_query->add_sql_clause( 'order_by', 'DESC' ); } } /** * Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_customer_subquery( $query_args ) { global $wpdb; $customer_filter = ''; if ( isset( $query_args['customer_type'] ) ) { if ( 'new' === strtolower( $query_args['customer_type'] ) ) { $customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 0"; } elseif ( 'returning' === strtolower( $query_args['customer_type'] ) ) { $customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 1"; } } return $customer_filter; } /** * Returns product attribute subquery elements used in JOIN and WHERE clauses, * based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_attribute_subqueries( $query_args ) { global $wpdb; $sql_clauses = array( 'join' => array(), 'where' => array(), ); $match_operator = $this->get_match_operator( $query_args ); $post_meta_comparators = array( '=' => 'attribute_is', '!=' => 'attribute_is_not', ); foreach ( $post_meta_comparators as $comparator => $arg ) { if ( ! isset( $query_args[ $arg ] ) || ! is_array( $query_args[ $arg ] ) ) { continue; } foreach ( $query_args[ $arg ] as $attribute_term ) { // We expect tuples. if ( ! is_array( $attribute_term ) || 2 !== count( $attribute_term ) ) { continue; } $term_id = ''; // If the tuple is numeric, assume these are IDs. if ( is_numeric( $attribute_term[0] ) && is_numeric( $attribute_term[1] ) ) { $attribute_id = intval( $attribute_term[0] ); $term_id = intval( $attribute_term[1] ); // Invalid IDs. if ( 0 === $attribute_id || 0 === $term_id ) { continue; } // @todo: Use wc_get_attribute () instead ? $attr_taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); // Invalid attribute ID. if ( empty( $attr_taxonomy ) ) { continue; } $attr_term = get_term_by( 'id', $term_id, $attr_taxonomy ); // Invalid term ID. if ( false === $attr_term ) { continue; } $meta_key = sanitize_title( $attr_taxonomy ); $meta_value = $attr_term->slug; } else { // Assume these are a custom attribute slug/value pair. $meta_key = esc_sql( $attribute_term[0] ); $meta_value = esc_sql( $attribute_term[1] ); $attr_term = get_term_by( 'slug', $meta_value, $meta_key ); if ( false !== $attr_term ) { $term_id = $attr_term->term_id; } } $join_alias = 'orderitemmeta1'; $table_to_join_on = "{$wpdb->prefix}wc_order_product_lookup"; if ( empty( $sql_clauses['join'] ) ) { $sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_items orderitems ON orderitems.order_id = {$table_to_join_on}.order_id"; } // If we're matching all filters (AND), we'll need multiple JOINs on postmeta. // If not, just one. if ( 'AND' === $match_operator || 1 === count( $sql_clauses['join'] ) ) { $join_idx = count( $sql_clauses['join'] ); $join_alias = 'orderitemmeta' . $join_idx; $sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_itemmeta as {$join_alias} ON {$join_alias}.order_item_id = {$table_to_join_on}.order_item_id"; } $in_comparator = '=' === $comparator ? 'in' : 'not in'; // Add subquery for products ordered using attributes not used in variations. $term_attribute_subquery = "select product_id from {$wpdb->prefix}wc_product_attributes_lookup where is_variation_attribute=0 and term_id = %s"; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $sql_clauses['where'][] = $wpdb->prepare( " ( ( {$join_alias}.meta_key = %s AND {$join_alias}.meta_value {$comparator} %s ) or ( {$wpdb->prefix}wc_order_product_lookup.variation_id = 0 and {$wpdb->prefix}wc_order_product_lookup.product_id {$in_comparator} ({$term_attribute_subquery}) ) )", $meta_key, $meta_value, $term_id, ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber } } // If we're matching multiple attributes and all filters (AND), make sure // we're matching attributes on the same product. $num_attribute_filters = count( $sql_clauses['join'] ); for ( $i = 2; $i < $num_attribute_filters; $i++ ) { $join_alias = 'orderitemmeta' . $i; $sql_clauses['join'][] = "AND orderitemmeta1.order_item_id = {$join_alias}.order_item_id"; } return $sql_clauses; } /** * Returns logic operator for WHERE subclause based on 'match' query argument. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_match_operator( $query_args ) { $operator = 'AND'; if ( ! isset( $query_args['match'] ) ) { return $operator; } if ( 'all' === strtolower( $query_args['match'] ) ) { $operator = 'AND'; } elseif ( 'any' === strtolower( $query_args['match'] ) ) { $operator = 'OR'; } return $operator; } /** * Returns filtered comma separated ids, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Query field to filter. * @param string $separator Field separator. * @return string */ protected function get_filtered_ids( $query_args, $field, $separator = ',' ) { global $wpdb; $ids_str = ''; $ids = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array(); /** * Filter the IDs before retrieving report data. * * Allows filtering of the objects included or excluded from reports. * * @param array $ids List of object Ids. * @param array $query_args The original arguments for the request. * @param string $field The object type. * @param string $context The data store context. */ $ids = apply_filters( 'woocommerce_analytics_' . $field, $ids, $query_args, $field, $this->context ); if ( ! empty( $ids ) ) { $placeholders = implode( $separator, array_fill( 0, count( $ids ), '%d' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $ids_str = $wpdb->prepare( "{$placeholders}", $ids ); /* phpcs:enable */ } return $ids_str; } /** * Assign report columns once full table name has been assigned. */ protected function assign_report_columns() {} } Reports/Variations/DataStore.php 0000777 00000043206 15252174340 0012737 0 ustar 00 <?php /** * API\Reports\Variations\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Variations\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'variations'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'variation_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'name' => 'strval', 'price' => 'floatval', 'image' => 'strval', 'permalink' => 'strval', 'sku' => 'strval', ); /** * Extended product attributes to include in the data. * * @var array */ protected $extended_attributes = array( 'name', 'price', 'image', 'permalink', 'stock_status', 'stock_quantity', 'low_stock_amount', 'sku', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'variations'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'product_id' => 'product_id', 'variation_id' => 'variation_id', 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $arg_name Target of the JOIN sql param. */ protected function add_from_sql_params( $query_args, $arg_name ) { global $wpdb; if ( 'sku' !== $query_args['orderby'] ) { return; } $table_name = self::get_db_table_name(); $join = "LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'"; if ( 'inner' === $arg_name ) { $this->subquery->add_sql_clause( 'join', $join ); } else { $this->add_sql_clause( 'join', $join ); } } /** * Generate a subquery for order_item_id based on the attribute filters. * * @param array $query_args Query arguments supplied by the user. * @return string */ protected function get_order_item_by_attribute_subquery( $query_args ) { $order_product_lookup_table = self::get_db_table_name(); $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { // Perform a subquery for DISTINCT order items that match our attribute filters. $attr_subquery = new SqlQuery( $this->context . '_attribute_subquery' ); $attr_subquery->add_sql_clause( 'select', "DISTINCT {$order_product_lookup_table}.order_item_id" ); $attr_subquery->add_sql_clause( 'from', $order_product_lookup_table ); if ( $this->should_exclude_simple_products( $query_args ) ) { $attr_subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" ); } foreach ( $attribute_subqueries['join'] as $attribute_join ) { $attr_subquery->add_sql_clause( 'join', $attribute_join ); } $operator = $this->get_match_operator( $query_args ); $attr_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' ); return "AND {$order_product_lookup_table}.order_item_id IN ({$attr_subquery->get_query_statement()})"; } return false; } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $order_stats_lookup_table = $wpdb->prefix . 'wc_order_stats'; $order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $where_subquery = array(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations > 0 ) { $this->add_from_sql_params( $query_args, 'outer' ); } else { $this->add_from_sql_params( $query_args, 'inner' ); } $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } $excluded_products = $this->get_excluded_products( $query_args ); if ( $excluded_products ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})" ); } if ( $included_variations ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" ); } elseif ( $this->should_exclude_simple_products( $query_args ) ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_stats_lookup_table} ON {$order_product_lookup_table}.order_id = {$order_stats_lookup_table}.order_id" ); $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } $attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args ); if ( $attribute_order_items_subquery ) { // JOIN on product lookup if we haven't already. if ( ! $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" ); } // Add subquery for matching attributes to WHERE. $this->subquery->add_sql_clause( 'where', $attribute_order_items_subquery ); } if ( 0 < count( $where_subquery ) ) { $operator = $this->get_match_operator( $query_args ); $this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' ); } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return self::get_db_table_name() . '.date_created'; } if ( 'sku' === $order_by ) { return 'meta_value'; } return $order_by; } /** * Enriches the product data with attributes specified by the extended_attributes. * * @param array $products_data Product data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$products_data, $query_args ) { foreach ( $products_data as $key => $product_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $extended_attributes = apply_filters( 'woocommerce_rest_reports_variations_extended_attributes', $this->extended_attributes, $product_data ); $parent_product = wc_get_product( $product_data['product_id'] ); $attributes = array(); // Base extended info off the parent variable product if the variation ID is 0. // This is caused by simple products with prior sales being converted into variable products. // See: https://github.com/woocommerce/woocommerce-admin/issues/2719. $variation_id = (int) $product_data['variation_id']; $variation_product = ( 0 === $variation_id ) ? $parent_product : wc_get_product( $variation_id ); // Fall back to the parent product if the variation can't be found. $extended_attributes_product = is_a( $variation_product, 'WC_Product' ) ? $variation_product : $parent_product; // If both product and variation is not found, set deleted to true. if ( ! $extended_attributes_product ) { $extended_info['deleted'] = true; } foreach ( $extended_attributes as $extended_attribute ) { $function = 'get_' . $extended_attribute; if ( is_callable( array( $extended_attributes_product, $function ) ) ) { $value = $extended_attributes_product->{$function}(); $extended_info[ $extended_attribute ] = $value; } } // If this is a variation, add its attributes. // NOTE: We don't fall back to the parent product here because it will include all possible attribute options. if ( 0 < $variation_id && is_callable( array( $variation_product, 'get_variation_attributes' ) ) ) { $variation_attributes = $variation_product->get_variation_attributes(); foreach ( $variation_attributes as $attribute_name => $attribute ) { $name = str_replace( 'attribute_', '', $attribute_name ); $option_term = get_term_by( 'slug', $attribute, $name ); $attributes[] = array( 'id' => wc_attribute_taxonomy_id_by_name( $name ), 'name' => str_replace( 'pa_', '', $name ), 'option' => $option_term && ! is_wp_error( $option_term ) ? $option_term->name : $attribute, ); } } $extended_info['attributes'] = $attributes; // If there is no set low_stock_amount, use the one in user settings. if ( '' === $extended_info['low_stock_amount'] ) { $extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $extended_info = $this->cast_numbers( $extended_info ); } $products_data[ $key ]['extended_info'] = $extended_info; } } /** * Returns if simple products should be excluded from the report. * * @internal * * @param array $query_args Query parameters. * * @return boolean */ protected function should_exclude_simple_products( array $query_args ) { return apply_filters( 'experimental_woocommerce_analytics_variations_should_exclude_simple_products', true, $query_args ); } /** * Fill missing extended_info.name for the deleted products. * * @param array $products Product data. */ protected function fill_deleted_product_name( array &$products ) { global $wpdb; $product_variation_ids = array(); // Find products with missing extended_info.name. foreach ( $products as $key => $product ) { if ( ! isset( $product['extended_info']['name'] ) ) { $product_variation_ids[ $key ] = array( 'product_id' => $product['product_id'], 'variation_id' => $product['variation_id'], ); } } if ( ! count( $product_variation_ids ) ) { return; } $where_clauses = implode( ' or ', array_map( function ( $ids ) { return "( product_lookup.product_id = {$ids['product_id']} and product_lookup.variation_id = {$ids['variation_id']} )"; }, $product_variation_ids ) ); $query = " select product_lookup.product_id, product_lookup.variation_id, order_items.order_item_name from {$wpdb->prefix}wc_order_product_lookup as product_lookup left join {$wpdb->prefix}woocommerce_order_items as order_items on product_lookup.order_item_id = order_items.order_item_id where {$where_clauses} group by product_lookup.product_id, product_lookup.variation_id, order_items.order_item_name "; // phpcs:ignore $results = $wpdb->get_results( $query ); $index = array(); foreach ( $results as $result ) { $index[ $result->product_id . '_' . $result->variation_id ] = $result->order_item_name; } foreach ( $product_variation_ids as $product_key => $ids ) { $product = $products[ $product_key ]; $index_key = $product['product_id'] . '_' . $product['variation_id']; if ( isset( $index[ $index_key ] ) ) { $products[ $product_key ]['extended_info']['name'] = $index[ $index_key ]; } } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['product_includes'] = array(); $defaults['variation_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_variations = ( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) ) ? $query_args['variation_includes'] : array(); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_variations ) > 0 ) { $total_results = count( $included_variations ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); if ( 'date' === $query_args['orderby'] ) { $this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" ); } $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) ); $ids_table = $this->get_ids_table( $included_variations, 'variation_id' ); $this->add_sql_clause( 'select', $join_selections ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.variation_id = {$table_name}.variation_id" ); $variations_query = $this->get_query_statement(); } else { $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); /** * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. * * @since 7.4.0 * @param array $query_args Query parameters. * @param SqlQuery $subquery Variations query class. */ apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); /* phpcs:enable */ $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } if ( in_array( $query_args['orderby'], array( 'items_sold', 'net_revenue', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', product_id, variation_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $variations_query = $this->subquery->get_query_statement(); } /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $product_data = $wpdb->get_results( $variations_query, ARRAY_A ); /* phpcs:enable */ if ( null === $product_data ) { return $data; } $this->include_extended_info( $product_data, $query_args ); if ( $query_args['extended_info'] ) { $this->fill_deleted_product_name( $product_data ); } $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); $data = (object) array( 'data' => $product_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', 'product_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'product_id, variation_id' ); } } Reports/Variations/Controller.php 0000777 00000033421 15252174340 0013172 0 ustar 00 <?php /** * REST API Reports products controller * * Handles requests to the /reports/products endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports products controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { // The controller does not use this trait. It's here for API backward compatibility. use OrderAwareControllerTrait; /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/variations'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'variations' => 'variation_includes', 'products' => 'product_includes', ); /** * Get data from `'variations'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'variations' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @since 6.5.0 * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_variations', $response, $report, $request ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); /** * Experimental: Filter the list of parameters provided when querying data from the data store. * * @ignore * * @param array $collection_params List of parameters. * * @since 6.5.0 */ $collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_collection_params', $this->get_collection_params() ); $registered = array_keys( $collection_params ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $args[ $param_name ] = $request[ $param_name ]; } } } return $args; } /** * Prepare links for the request. * * @param array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), ), 'variation' => array( 'href' => rest_url( sprintf( '/%s/%s/%d/%s/%d', $this->namespace, 'products', $object['product_id'], 'variation', $object['variation_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_varitations', 'type' => 'object', 'properties' => array( 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'variation_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'items_sold' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of items sold.', 'woocommerce' ), ), 'net_revenue' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ), ), 'orders_count' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of orders product appeared in.', 'woocommerce' ), ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product name.', 'woocommerce' ), ), 'price' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product price.', 'woocommerce' ), ), 'image' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product image.', 'woocommerce' ), ), 'permalink' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product link.', 'woocommerce' ), ), 'attributes' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product attributes.', 'woocommerce' ), ), 'stock_status' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory status.', 'woocommerce' ), ), 'stock_quantity' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory quantity.', 'woocommerce' ), ), 'low_stock_amount' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'items_sold', 'sku', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each variation to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to variations that include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to variations that don\'t include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['category_includes'] = array( 'description' => __( 'Limit result set to variations in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['category_excludes'] = array( 'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); return $params; } /** * Get stock status column export value. * * @param array $status Stock status from report row. * @return string */ protected function get_stock_status( $status ) { $statuses = wc_get_product_stock_status_options(); return isset( $statuses[ $status ] ) ? $statuses[ $status ] : ''; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'product_name' => __( 'Product / Variation title', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'N. Revenue', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_columns['stock_status'] = __( 'Status', 'woocommerce' ); $export_columns['stock'] = __( 'Stock', 'woocommerce' ); } return $export_columns; } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $product_name = $item['extended_info']['name']; /** * Filter the separator used in the product variation title. * * @since 10.2.0 * @param string $separator The separator. * @param \WC_Product $product The product object. * @return string The separator. */ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() ); if ( ! empty( $item['extended_info']['attributes'] ) && strpos( $product_name, $separator ) === false ) { $attributes = array(); foreach ( $item['extended_info']['attributes'] as $attribute ) { if ( empty( $attribute['option'] ) ) { // translators: %s: the attribute name. $attributes[] = sprintf( __( 'Any %s', 'woocommerce' ), ucfirst( $attribute['name'] ) ); } else { $attributes[] = $attribute['option']; } } $product_name .= $separator . implode( ', ', $attributes ); } $export_item = array( 'product_name' => $product_name, 'sku' => $item['extended_info']['sku'], 'items_sold' => $item['items_sold'], 'net_revenue' => self::csv_number_format( $item['net_revenue'] ), 'orders_count' => $item['orders_count'], ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] ); $export_item['stock'] = $item['extended_info']['stock_quantity']; } return $export_item; } } Reports/Variations/Stats/Segmenter.php 0000777 00000017737 15252174340 0014112 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'items_sold' => "SUM($products_table.product_qty) as items_sold", 'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue", 'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count", 'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count", ); return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); /* phpcs:enable */ $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to a multiple of the number of segments. preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts ); $segment_count = count( $this->get_all_segments() ); $orig_offset = intval( $limit_parts[1] ); $orig_rowcount = intval( $limit_parts[2] ); $segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array|null * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segments = null; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'variation' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; // Restrict our search space for variation comparisons. if ( isset( $this->query_args['variation_includes'] ) ) { $variation_ids = implode( ',', $this->get_all_segments() ); $segmenting_where = " AND $product_segmenting_table.variation_id IN ( $variation_ids )"; } $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } return $segments; } } Reports/Variations/Stats/Controller.php 0000777 00000023001 15252174340 0014261 0 ustar 00 <?php /** * REST API Reports variations stats controller * * Handles requests to the /reports/variations/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports variations stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/variations/stats'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'variations' => 'variation_includes', ); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_variations_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Get data from `'variations-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'variations-stats' ); return $query->get_data(); } /** * Maps query arguments from the REST request, to be fed to Query. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', 'net_revenue', 'orders_count', 'variations_count', ), ); /** * Experimental: Filter the list of parameters provided when querying data from the data store. * * @ignore * * @param array $collection_params List of parameters. */ $collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_stats_collection_params', $this->get_collection_params() ); $registered = array_keys( $collection_params ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $query_args[ $param_name ] = $request[ $param_name ]; } } } return $query_args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_variations_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'items_sold' => array( 'title' => __( 'Variations Sold', 'woocommerce' ), 'description' => __( 'Number of variation items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_variations_stats'; $segment_label = array( 'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ); $schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; return $this->add_additional_fields_schema( $schema ); } /** * Set the default results to 0 if API returns an empty array * * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->items_sold = 0; $results->totals->net_revenue = 0; $results->totals->orders_count = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', ) ); $params['category_includes'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['category_excludes'] = array( 'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Variations/Stats/DataStore.php 0000777 00000025011 15252174340 0014027 0 ustar 00 <?php /** * API\Reports\Products\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Variations\Stats\DataStore. */ class DataStore extends VariationsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override VariationsDataStore::$column_types * * @var array */ protected $column_types = array( 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'variations_count' => 'intval', ); /** * Cache identifier. * * @override VariationsDataStore::$cache_key * * @var string */ protected $cache_key = 'variations_stats'; /** * Data store context used to pass to filters. * * @override VariationsDataStore::$context * * @var string */ protected $context = 'variations_stats'; /** * Assign report columns once full table name has been assigned. * * @override VariationsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $products_where_clause = ''; $products_from_clause = ''; $where_subquery = array(); $order_product_lookup_table = self::get_db_table_name(); $order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; } $excluded_products = $this->get_excluded_products( $query_args ); if ( $excluded_products ) { $products_where_clause .= "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})"; } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})"; } elseif ( $this->should_exclude_simple_products( $query_args ) ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id != 0"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $products_where_clause .= " AND ( {$order_status_filter} )"; } $attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args ); if ( $attribute_order_items_subquery ) { // JOIN on product lookup if we haven't already. if ( ! $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; } // Add subquery for matching attributes to WHERE. $products_where_clause .= $attribute_order_items_subquery; } if ( 0 < count( $where_subquery ) ) { $operator = $this->get_match_operator( $query_args ); $products_where_clause .= 'AND (' . implode( " {$operator} ", $where_subquery ) . ')'; } $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->total_query->add_sql_clause( 'where', $products_where_clause ); $this->total_query->add_sql_clause( 'join', $products_from_clause ); $this->add_intervals_sql_params( $query_args, $order_product_lookup_table ); $this->interval_query->add_sql_clause( 'where', $products_where_clause ); $this->interval_query->add_sql_clause( 'join', $products_from_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } /** * Returns if simple products should be excluded from the report. * * @internal * * @param array $query_args Query parameters. * * @return boolean */ protected function should_exclude_simple_products( array $query_args ) { return apply_filters( 'experimental_woocommerce_analytics_variations_stats_should_exclude_simple_products', true, $query_args ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override VariationsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['interval'] = 'week'; unset( $defaults['extended_info'] ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override VariationsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->update_sql_query_params( $query_args ); $this->get_limit_sql_params( $query_args ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $db_intervals = $wpdb->get_col( $this->interval_query->get_query_statement() ); /* phpcs:enable */ $db_interval_count = count( $db_intervals ); $intervals = array(); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $totals = $wpdb->get_results( $this->total_query->get_query_statement(), ARRAY_A ); /* phpcs:enable */ // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'order_by' => $this->get_sql_clause( 'order_by' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $intervals = $wpdb->get_results( $this->interval_query->get_query_statement(), ARRAY_A ); /* phpcs:enable */ if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override VariationsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } Reports/Variations/Stats/Query.php 0000777 00000003747 15252174340 0013262 0 ustar 00 <?php /** * Class for parameter-based Variations Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Stats\Query * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get variations data based on the current query vars. * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_variations_stats_select_query', $results, $args ); } } Reports/Variations/Query.php 0000777 00000003645 15252174340 0012161 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Query * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_variations_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_variations_select_query', $results, $args ); } } Reports/FilteredGetDataTrait.php 0000777 00000003065 15252174340 0012725 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Trait to call filters on `get_data` methods for data stores. * * It calls the filters `woocommerce_analytics_{$this->context}_query_args` and * `woocommerce_analytics_{$this->context}_select_query` on the `get_data` method. * * Example: * <pre><code class="language-php">class MyStatsDataStore extends DataStore implements DataStoreInterface { * // Use the trait. * use FilteredGetDataTrait; * // Provide all the necessary properties and methods for a regular DataStore. * // ... * } * </code></pre> * * @see DataStore */ trait FilteredGetDataTrait { /** * Get the data based on args. * * Filters query args, calls DataStore::get_data, and returns the filtered data. * * @override ReportsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error */ public function get_data( $query_args ) { /** * Called before the data is fetched. * * @since 9.3.0 * @param array $query_args Query parameters. */ $args = apply_filters( "woocommerce_analytics_{$this->context}_query_args", $query_args ); $results = parent::get_data( $args ); /** * Called after the data is fetched. * The results can be modified here. * * @since 9.3.0 * @param stdClass|WP_Error $results The results of the query. */ return apply_filters( "woocommerce_analytics_{$this->context}_select_query", $results, $args ); } } Reports/Downloads/DataStore.php 0000777 00000031062 15252174340 0012547 0 ustar 00 <?php /** * API\Reports\Downloads\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Downloads\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_download_log'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'downloads'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'id' => 'intval', 'date' => 'strval', 'date_gmt' => 'strval', 'download_id' => 'strval', // String because this can sometimes be a hash. 'file_name' => 'strval', 'product_id' => 'intval', 'order_id' => 'intval', 'user_id' => 'intval', 'ip_address' => 'strval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'downloads'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'id' => 'download_log_id as id', 'date' => 'timestamp as date_gmt', 'download_id' => 'product_permissions.download_id', 'product_id' => 'product_permissions.product_id', 'order_id' => 'product_permissions.order_id', 'user_id' => 'product_permissions.user_id', 'ip_address' => 'user_ip_address as ip_address', ); } /** * Updates the database query with parameters used for downloads report. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $lookup_table = self::get_db_table_name(); $permission_table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions'; $operator = $this->get_match_operator( $query_args ); $where_filters = array(); $join = "JOIN {$permission_table} as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id"; $where_time = $this->add_time_period_sql_params( $query_args, $lookup_table ); if ( $where_time ) { if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', $where_time ); } else { $this->interval_query->add_sql_clause( 'where_time', $where_time ); } } $this->get_limit_sql_params( $query_args ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'product_id', 'IN', $this->get_included_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'product_id', 'NOT IN', $this->get_excluded_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'order_id', 'IN', $this->get_included_orders( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'order_id', 'NOT IN', $this->get_excluded_orders( $query_args ) ); $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer_lookup = "SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN (%s)"; $included_customers = $this->get_included_customers( $query_args ); $excluded_customers = $this->get_excluded_customers( $query_args ); if ( $included_customers ) { $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'user_id', 'IN', sprintf( $customer_lookup, $included_customers ) ); } if ( $excluded_customers ) { $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'user_id', 'NOT IN', sprintf( $customer_lookup, $excluded_customers ) ); } $included_ip_addresses = $this->get_included_ip_addresses( $query_args ); $excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args ); if ( $included_ip_addresses ) { $where_filters[] = "{$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')"; } if ( $excluded_ip_addresses ) { $where_filters[] = "{$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')"; } $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); if ( $where_subclause ) { if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where', "AND ( $where_subclause )" ); } else { $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); } } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'join', $join ); } else { $this->interval_query->add_sql_clause( 'join', $join ); } $this->add_order_by( $query_args ); } /** * Returns comma separated ids of included ip address, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_ip_addresses( $query_args ) { return $this->get_filtered_ip_addresses( $query_args, 'ip_address_includes' ); } /** * Returns comma separated ids of excluded ip address, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_ip_addresses( $query_args ) { return $this->get_filtered_ip_addresses( $query_args, 'ip_address_excludes' ); } /** * Returns filtered comma separated ids, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Query field to filter. * @return string */ protected function get_filtered_ip_addresses( $query_args, $field ) { if ( isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) && count( $query_args[ $field ] ) > 0 ) { $ip_addresses = array_map( 'esc_sql', $query_args[ $field ] ); /** * Filter the IDs before retrieving report data. * * Allows filtering of the objects included or excluded from reports. * * @param array $ids List of object Ids. * @param array $query_args The original arguments for the request. * @param string $field The object type. * @param string $context The data store context. */ $ip_addresses = apply_filters( 'woocommerce_analytics_' . $field, $ip_addresses, $query_args, $field, $this->context ); return implode( "','", $ip_addresses ); } return ''; } /** * Returns comma separated ids of included customers, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_customers( $query_args ) { return self::get_filtered_ids( $query_args, 'customer_includes' ); } /** * Returns comma separated ids of excluded customers, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_customers( $query_args ) { return self::get_filtered_ids( $query_args, 'customer_excludes' ); } /** * Gets WHERE time clause of SQL request with date-related constraints. * * @override ReportsDataStore::add_time_period_sql_params() * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. * @return string */ protected function add_time_period_sql_params( $query_args, $table_name ) { $where_time = ''; if ( $query_args['before'] ) { $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); $where_time .= " AND {$table_name}.timestamp <= '$datetime_str'"; } if ( $query_args['after'] ) { $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); $where_time .= " AND {$table_name}.timestamp >= '$datetime_str'"; } return $where_time; } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. */ protected function add_order_by( $query_args ) { global $wpdb; $this->clear_sql_clause( 'order_by' ); $order_by = ''; if ( isset( $query_args['orderby'] ) ) { $order_by = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); $this->add_sql_clause( 'order_by', $order_by ); } if ( false !== strpos( $order_by, '_products' ) ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->posts} AS _products ON product_permissions.product_id = _products.ID" ); } $this->add_orderby_order_clause( $query_args, $this ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'timestamp'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $params = $this->get_limit_params( $query_args ); $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $download_data = $wpdb->get_results( $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $download_data ) { return $data; } $download_data = array_map( array( $this, 'cast_numbers' ), $download_data ); $data = (object) array( 'data' => $download_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { global $wpdb; if ( 'date' === $order_by ) { return $wpdb->prefix . 'wc_download_log.timestamp'; } if ( 'product' === $order_by ) { return '_products.post_title'; } return $order_by; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $table_name = self::get_db_table_name(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', $table_name ); $this->subquery->add_sql_clause( 'select', "{$table_name}.download_log_id" ); $this->subquery->add_sql_clause( 'group_by', "{$table_name}.download_log_id" ); } } Reports/Downloads/Controller.php 0000777 00000027370 15252174340 0013013 0 ustar 00 <?php /** * REST API Reports downloads controller * * Handles requests to the /reports/downloads endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports downloads controller class. * * @internal * @extends Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads'; /** * Get data from `'downloads'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'downloads' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); $response->data['date'] = get_date_from_gmt( $report['date_gmt'], 'Y-m-d H:i:s' ); // Figure out file name. // Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197. $product_id = intval( $report['product_id'] ); $_product = wc_get_product( $product_id ); // Make sure the product hasn't been deleted. if ( $_product ) { $file_path = $_product->get_file_download_path( $report['download_id'] ); $filename = basename( $file_path ); $response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id ); $response->data['file_path'] = $file_path; } else { $response->data['file_name'] = ''; $response->data['file_path'] = ''; } $customer = new \WC_Customer( $report['user_id'] ); $response->data['username'] = $customer->get_username(); $response->data['order_number'] = $this->get_order_number( $report['order_id'] ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_downloads', $response, $report, $request ); } /** * Prepare links for the request. * * @param Array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), 'embeddable' => true, ), ); return $links; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { $args[ $param_name ] = $request[ $param_name ]; } } return $args; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_downloads', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'ID.', 'woocommerce' ), ), 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'date' => array( 'description' => __( "The date of the download, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_gmt' => array( 'description' => __( 'The date of the download, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'download_id' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Download ID.', 'woocommerce' ), ), 'file_name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'File name.', 'woocommerce' ), ), 'file_path' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'File URL.', 'woocommerce' ), ), 'order_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order ID.', 'woocommerce' ), ), 'order_number' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order Number.', 'woocommerce' ), ), 'user_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'User ID for the downloader.', 'woocommerce' ), ), 'username' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'User name of the downloader.', 'woocommerce' ), ), 'ip_address' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'IP address for the downloader.', 'woocommerce' ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'product', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: products, orders, username, ip_address.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_includes'] = array( 'description' => __( 'Limit response to objects that have the specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have the specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['ip_address_includes'] = array( 'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['ip_address_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'date' => __( 'Date', 'woocommerce' ), 'product' => __( 'Product title', 'woocommerce' ), 'file_name' => __( 'File name', 'woocommerce' ), 'order_number' => __( 'Order #', 'woocommerce' ), 'user_id' => __( 'User Name', 'woocommerce' ), 'ip_address' => __( 'IP', 'woocommerce' ), ); /** * Filter to add or remove column names from the downloads report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_filter_downloads_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'date' => $item['date'], 'product' => $item['_embedded']['product'][0]['name'], 'file_name' => $item['file_name'], 'order_number' => $item['order_number'], 'user_id' => $item['username'], 'ip_address' => $item['ip_address'], ); /** * Filter to prepare extra columns in the export item for the downloads * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_downloads_prepare_export_item', $export_item, $item ); } } Reports/Downloads/Files/Controller.php 0000777 00000001122 15252174340 0014040 0 ustar 00 <?php /** * REST API Reports downloads files controller * * Handles requests to the /reports/downloads/files endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Files; defined( 'ABSPATH' ) || exit; /** * REST API Reports downloads files controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads/files'; } Reports/Downloads/Query.php 0000777 00000003534 15252174340 0011771 0 ustar 00 <?php /** * Class for parameter-based downloads report querying. * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Downloads\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Query * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for downloads report. * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get downloads data based on the current query vars. * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_downloads_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_downloads_select_query', $results, $args ); } } Reports/Downloads/Stats/Controller.php 0000777 00000023417 15252174340 0014107 0 ustar 00 <?php /** * REST API Reports downloads stats controller * * Handles requests to the /reports/downloads/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports downloads stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['match'] = $request['match']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['customer_includes'] = (array) $request['customer_includes']; $args['customer_excludes'] = (array) $request['customer_excludes']; $args['order_includes'] = (array) $request['order_includes']; $args['order_excludes'] = (array) $request['order_excludes']; $args['ip_address_includes'] = (array) $request['ip_address_includes']; $args['ip_address_excludes'] = (array) $request['ip_address_excludes']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'downloads-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'downloads-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_downloads_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'download_count' => array( 'title' => __( 'Downloads', 'woocommerce' ), 'description' => __( 'Number of downloads.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * It does not have the segments as in GenericStatsController. * * @return array */ public function get_item_schema() { $totals = $this->get_item_properties_schema(); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_orders_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), 'intervals' => array( 'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'interval' => array( 'description' => __( 'Type of interval.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ), 'date_start' => array( 'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_start_gmt' => array( 'description' => __( 'The date the report start, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end' => array( 'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end_gmt' => array( 'description' => __( 'The date the report end, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'download_count', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_includes'] = array( 'description' => __( 'Limit response to objects that have the specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have the specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['ip_address_includes'] = array( 'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['ip_address_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } } Reports/Downloads/Stats/DataStore.php 0000777 00000014226 15252174340 0013650 0 ustar 00 <?php /** * API\Reports\Downloads\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Downloads\Stats\DataStore. */ class DataStore extends DownloadsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override DownloadsDataStore::$column_types * * @var array */ protected $column_types = array( 'download_count' => 'intval', ); /** * Cache identifier. * * @override DownloadsDataStore::$cache_key * * @var string */ protected $cache_key = 'downloads_stats'; /** * Data store context used to pass to filters. * * @override DownloadsDataStore::$context * * @var string */ protected $context = 'downloads_stats'; /** * Assign report columns once full table name has been assigned. * * @override DownloadsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'download_count' => 'COUNT(DISTINCT download_log_id) as download_count', ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override DownloadsDataStore::default_query_args() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['interval'] = 'week'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override DownloadsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); $where_time = $this->add_time_period_sql_params( $query_args, $table_name ); $this->add_intervals_sql_params( $query_args, $table_name ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' ); $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_records_count = count( $db_intervals ); $this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name ); $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) ); if ( $where_time ) { $this->total_query->add_sql_clause( 'where_time', $where_time ); } $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override DownloadsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } Reports/Downloads/Stats/Query.php 0000777 00000003075 15252174340 0013067 0 ustar 00 <?php /** * Class for parameter-based downloads Reports querying */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Stats\Query * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Orders report. * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get revenue data based on the current query vars. * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_downloads_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_downloads_stats_select_query', $results, $args ); } } Reports/Stock/Controller.php 0000777 00000041016 15252174340 0012135 0 ustar 00 <?php /** * REST API Reports stock controller * * Handles requests to the /reports/stock endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Enums\ProductType; use WP_REST_Request; use WP_REST_Response; use Automattic\WooCommerce\Enums\ProductStockStatus; /** * REST API Reports stock controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/stock'; /** * Registered stock status options. * * @var array */ protected $status_options; /** * Constructor. */ public function __construct() { $this->status_options = wc_get_product_stock_status_options(); } /** * Maps query arguments from the REST request. * * @param WP_REST_Request $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['offset'] = $request['offset']; $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['paged'] = $request['page']; $args['post__in'] = $request['include']; $args['post__not_in'] = $request['exclude']; $args['posts_per_page'] = $request['per_page']; $args['post_parent__in'] = $request['parent']; $args['post_parent__not_in'] = $request['parent_exclude']; if ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date ID'; } elseif ( 'include' === $args['orderby'] ) { $args['orderby'] = 'post__in'; } elseif ( 'id' === $args['orderby'] ) { $args['orderby'] = 'ID'; // ID must be capitalized. } $args['post_type'] = array( 'product', 'product_variation' ); if ( ProductStockStatus::LOW_STOCK === $request['type'] ) { $args['low_in_stock'] = true; } elseif ( in_array( $request['type'], array_keys( $this->status_options ), true ) ) { $args['stock_status'] = $request['type']; } $args['ignore_sticky_posts'] = true; return $args; } /** * Query products. * * @param array $query_args Query args. * @return array */ protected function get_products( $query_args ) { $query = new \WP_Query(); $result = $query->query( $query_args ); $total_posts = $query->found_posts; if ( $total_posts < 1 && isset( $query_args['paged'] ) && absint( $query_args['paged'] ) > 1 ) { // Out-of-bounds, run the query again without LIMIT for total count. unset( $query_args['paged'] ); $count_query = new \WP_Query(); $count_query->query( $query_args ); $total_posts = $count_query->found_posts; } return array( 'objects' => array_map( 'wc_get_product', $result ), 'total' => (int) $total_posts, 'pages' => (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ), ); } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 ); add_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10, 2 ); $query_args = $this->prepare_reports_query( $request ); $query_results = $this->get_products( $query_args ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 ); remove_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10 ); $objects = array(); foreach ( $query_results['objects'] as $object ) { $data = $this->prepare_item_for_response( $object, $request ); $objects[] = $this->prepare_response_for_collection( $data ); } return $this->add_pagination_headers( $request, $objects, (int) $query_results['total'], (int) $query_args['paged'], (int) $query_results['pages'] ); } /** * Add in conditional search filters for products. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $stock_status = $wp_query->get( 'stock_status' ); if ( $stock_status ) { $where .= $wpdb->prepare( ' AND wc_product_meta_lookup.stock_status = %s ', $stock_status ); } if ( $wp_query->get( 'low_in_stock' ) ) { // We want products with stock < low stock amount, but greater than no stock amount. $no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) ); $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $where .= " AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status = 'instock' AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount} ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount} AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount} ) )"; } return $where; } /** * Join posts meta tables when product search or low stock query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $stock_status = $wp_query->get( 'stock_status' ); if ( $stock_status ) { $join = self::append_product_sorting_table_join( $join ); } if ( $wp_query->get( 'low_in_stock' ) ) { $join = self::append_product_sorting_table_join( $join ); $join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' "; } return $join; } /** * Join wc_product_meta_lookup to posts if not already joined. * * @internal * @param string $sql SQL join. * @return string */ protected static function append_product_sorting_table_join( $sql ) { global $wpdb; if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $sql; } /** * Group by post ID to prevent duplicates. * * @internal * @param string $groupby Group by clause used to organize posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_group_by( $groupby, $wp_query ) { global $wpdb; if ( empty( $groupby ) ) { $groupby = $wpdb->posts . '.ID'; } return $groupby; } /** * Custom orderby clauses using the lookup tables. * * @internal * @param array $args Query args. * @param object $wp_query WP_Query object. * @return array */ public static function add_wp_query_orderby( $args, $wp_query ) { global $wpdb; $orderby = $wp_query->get( 'orderby' ); $order = esc_sql( $wp_query->get( 'order' ) ? $wp_query->get( 'order' ) : 'desc' ); switch ( $orderby ) { case 'stock_quantity': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.stock_quantity {$order}, wc_product_meta_lookup.product_id {$order} "; break; case 'stock_status': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.stock_status {$order}, wc_product_meta_lookup.stock_quantity {$order} "; break; case 'sku': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.sku {$order}, wc_product_meta_lookup.product_id {$order} "; break; } return $args; } /** * Prepare a report data item for serialization. * * @param WC_Product $product Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $product, $request ) { $data = array( 'id' => $product->get_id(), 'parent_id' => $product->get_parent_id(), 'name' => wp_strip_all_tags( $product->get_name() ), 'sku' => $product->get_sku(), 'stock_status' => $product->get_stock_status(), 'stock_quantity' => (float) $product->get_stock_quantity(), 'manage_stock' => $product->get_manage_stock(), 'low_stock_amount' => $product->get_low_stock_amount(), ); if ( '' === $data['low_stock_amount'] ) { $data['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $response = parent::prepare_item_for_response( $data, $request ); $response->add_links( $this->prepare_links( $product ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param WC_Product $product The original product object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_stock', $response, $product, $request ); } /** * Prepare links for the request. * * @param WC_Product $product Object data. * @return array */ protected function prepare_links( $product ) { if ( $product->is_type( ProductType::VARIATION ) ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d/variations/%d', $this->namespace, $product->get_parent_id(), $product->get_id() ) ), ), 'parent' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ), ), ); } elseif ( $product->get_parent_id() ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ), ), 'parent' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ), ), ); } else { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ), ), ); } return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_stock', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'parent_id' => array( 'description' => __( 'Product parent ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Product name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'sku' => array( 'description' => __( 'Unique identifier.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'stock_status' => array( 'description' => __( 'Stock status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_keys( wc_get_product_stock_status_options() ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'stock_quantity' => array( 'description' => __( 'Stock quantity.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'manage_stock' => array( 'description' => __( 'Manage stock.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['after'], $params['before'], $params['force_cache_refresh'] ); $params['exclude'] = array( 'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['include'] = array( 'description' => __( 'Limit result set to specific ids.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['offset'] = array( 'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['order']['default'] = 'asc'; $params['orderby']['default'] = 'stock_status'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'stock_status', 'stock_quantity', 'date', 'id', 'include', 'title', 'sku', ) ); $params['parent'] = array( 'description' => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'sanitize_callback' => 'wp_parse_id_list', 'default' => array(), ); $params['parent_exclude'] = array( 'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'sanitize_callback' => 'wp_parse_id_list', 'default' => array(), ); $params['type'] = array( 'description' => __( 'Limit result set to items assigned a stock report type.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array_merge( array( 'all', 'lowstock' ), array_keys( wc_get_product_stock_status_options() ) ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'title' => __( 'Product / Variation', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'stock_status' => __( 'Status', 'woocommerce' ), 'stock_quantity' => __( 'Stock', 'woocommerce' ), ); /** * Filter to add or remove column names from the stock report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_stock_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $status = $item['stock_status']; if ( array_key_exists( $item['stock_status'], $this->status_options ) ) { $status = $this->status_options[ $item['stock_status'] ]; } $export_item = array( 'title' => $item['name'], 'sku' => $item['sku'], 'stock_status' => $status, 'stock_quantity' => $item['stock_quantity'], ); /** * Filter to prepare extra columns in the export item for the stock * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_stock_prepare_export_item', $export_item, $item ); } } Reports/Stock/Stats/Controller.php 0000777 00000007203 15252174340 0013233 0 ustar 00 <?php /** * REST API Reports stock stats controller * * Handles requests to the /reports/stock/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; /** * REST API Reports stock stats controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/stock/stats'; /** * Get Stock Status Totals. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $stock_query = new Query(); $report_data = $stock_query->get_data(); $out_data = array( 'totals' => $report_data, ); return rest_ensure_response( $out_data ); } /** * Prepare a report data item for serialization. * * @param WC_Product $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = $report; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @since 6.5.0 * * @param WP_REST_Response $response The response object. * @param WC_Product $report The original object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_stock_stats', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $totals = array( 'products' => array( 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'lowstock' => array( 'description' => __( 'Number of low stock products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); $status_options = wc_get_product_stock_status_options(); foreach ( $status_options as $status => $label ) { $totals[ $status ] = array( /* translators: Stock status. Example: "Number of low stock products */ 'description' => sprintf( __( 'Number of %s products.', 'woocommerce' ), $label ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); return $params; } } Reports/Stock/Stats/DataStore.php 0000777 00000010756 15252174340 0013005 0 ustar 00 <?php /** * API\Reports\Stock\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Enums\ProductStockStatus; /** * API\Reports\Stock\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Get stock counts for the whole store. * * @override ReportsDataStore::get_data() * * @param array $query Not used for the stock stats data store, but needed for the interface. * @return array Array of counts. */ public function get_data( $query ) { $report_data = array(); $cache_expire = DAY_IN_SECONDS * 30; $low_stock_transient_name = 'wc_admin_stock_count_lowstock'; $low_stock_count = get_transient( $low_stock_transient_name ); if ( false === $low_stock_count ) { $low_stock_count = $this->get_low_stock_count(); set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire ); } else { $low_stock_count = intval( $low_stock_count ); } $report_data[ ProductStockStatus::LOW_STOCK ] = $low_stock_count; $status_options = wc_get_product_stock_status_options(); foreach ( $status_options as $status => $label ) { $transient_name = 'wc_admin_stock_count_' . $status; $count = get_transient( $transient_name ); if ( false === $count ) { $count = $this->get_count( $status ); set_transient( $transient_name, $count, $cache_expire ); } else { $count = intval( $count ); } $report_data[ $status ] = $count; } $product_count_transient_name = 'wc_admin_product_count'; $product_count = get_transient( $product_count_transient_name ); if ( false === $product_count ) { $product_count = $this->get_product_count(); set_transient( $product_count_transient_name, $product_count, $cache_expire ); } else { $product_count = intval( $product_count ); } $report_data['products'] = $product_count; return $report_data; } /** * Get low stock count (products with stock < low stock amount, but greater than no stock amount). * * @return int Low stock count. */ private function get_low_stock_count() { global $wpdb; $no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) ); $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); return (int) $wpdb->get_var( $wpdb->prepare( " SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id LEFT JOIN {$wpdb->postmeta} low_stock_amount_meta ON posts.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' WHERE posts.post_type IN ( 'product', 'product_variation' ) AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status = 'instock' AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) AND wc_product_meta_lookup.stock_quantity > %d ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= %d AND wc_product_meta_lookup.stock_quantity > %d ) ) ", $no_stock_amount, $low_stock_amount, $no_stock_amount ) ); } /** * Get count for the passed in stock status. * * @param string $status Status slug. * @return int Count. */ private function get_count( $status ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( " SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id WHERE posts.post_type IN ( 'product', 'product_variation' ) AND wc_product_meta_lookup.stock_status = %s ", $status ) ); } /** * Get product count for the store. * * @return int Product count. */ private function get_product_count() { $query_args = array(); $query_args['post_type'] = array( 'product', 'product_variation' ); $query = new \WP_Query(); $query->query( $query_args ); return intval( $query->found_posts ); } } Reports/Stock/Stats/Query.php 0000777 00000001327 15252174340 0012216 0 ustar 00 <?php /** * Class for stock stats report querying * * $report = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query(); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; /** * API\Reports\Stock\Stats\Query * This query takes no arguments, so we do not inherit from GenericQuery. */ class Query extends \WC_Object_Query { /** * Get product data based on the current query vars. * * @return array */ public function get_data() { $data_store = \WC_Data_Store::load( 'report-stock-stats' ); $results = $data_store->get_data(); return apply_filters( 'woocommerce_analytics_stock_stats_query', $results ); } } Reports/Query.php 0000777 00000002646 15252174340 0010042 0 ustar 00 <?php /** * Class for parameter-based Reports querying */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * Admin\API\Reports\Query * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ abstract class Query extends \WC_Object_Query { /** * Create a new query. * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @param array $args Criteria to query on in a format similar to WP_Query. */ public function __construct( $args = array() ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); parent::__construct( $args ); } /** * Get report data matching the current query vars. * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array|object of WC_Product objects */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); /* translators: %s: Method name */ return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); } } Reports/Coupons/DataStore.php 0000777 00000040313 15252174340 0012242 0 ustar 00 <?php /** * API\Reports\Coupons\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * API\Reports\Coupons\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_coupon_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'coupons'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'coupon_id' => 'intval', 'amount' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'coupons'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'coupon_id' => 'coupon_id', 'amount' => 'SUM(discount_amount) as amount', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } // This method was already available as non-final, marking it as final now would make it backwards-incompatible. // phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingFinal /** * Set up all the hooks for maintaining and populating table data. * * @internal */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 ); } // phpcs:enable WooCommerce.Functions.InternalInjectionMethod.MissingFinal /** * Returns an array of ids of included coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_coupons_array( $query_args ) { if ( isset( $query_args['coupons'] ) && is_array( $query_args['coupons'] ) && count( $query_args['coupons'] ) > 0 ) { return $query_args['coupons']; } return array(); } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_coupon_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table ); $this->get_limit_sql_params( $query_args ); $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { $this->subquery->add_sql_clause( 'where', "AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})" ); $this->add_order_by_params( $query_args, 'outer', 'default_results.coupon_id' ); } else { $this->add_order_by_params( $query_args, 'inner', "{$order_coupon_lookup_table}.coupon_id" ); } $this->add_order_status_clause( $query_args, $order_coupon_lookup_table, $this->subquery ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; // Sanitize input: guarantee that the id cell in the join is quoted with backticks. $id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) ); $id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`'; $lookup_table = self::get_db_table_name(); $order_by_clause = $this->add_order_by_clause( $query_args, $this ); $join = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell_identifier} = _coupons.ID"; $this->add_orderby_order_clause( $query_args, $this ); if ( 'inner' === $from_arg ) { $this->subquery->clear_sql_clause( 'join' ); if ( false !== strpos( $order_by_clause, '_coupons' ) ) { $this->subquery->add_sql_clause( 'join', $join ); } } else { $this->clear_sql_clause( 'join' ); if ( false !== strpos( $order_by_clause, '_coupons' ) ) { $this->add_sql_clause( 'join', $join ); } } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } if ( 'code' === $order_by ) { return '_coupons.post_title'; } return $order_by; } /** * Enriches the coupon data with extra attributes. * * @param array $coupon_data Coupon data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$coupon_data, $query_args ) { foreach ( $coupon_data as $idx => $coupon_datum ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $coupon_id = $coupon_datum['coupon_id']; $coupon = new \WC_Coupon( $coupon_id ); if ( 0 === $coupon->get_id() ) { // Deleted or otherwise invalid coupon. $extended_info = array( 'code' => __( '(Deleted)', 'woocommerce' ), 'date_created' => '', 'date_created_gmt' => '', 'date_expires' => '', 'date_expires_gmt' => '', 'discount_type' => __( 'N/A', 'woocommerce' ), ); } else { $gmt_timzone = new \DateTimeZone( 'UTC' ); $date_expires = $coupon->get_date_expires(); if ( is_a( $date_expires, 'DateTime' ) ) { $date_expires = $date_expires->format( TimeInterval::$iso_datetime_format ); $date_expires_gmt = new \DateTime( $date_expires ); $date_expires_gmt->setTimezone( $gmt_timzone ); $date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format ); } else { $date_expires = ''; $date_expires_gmt = ''; } $date_created = $coupon->get_date_created(); if ( is_a( $date_created, 'DateTime' ) ) { $date_created = $date_created->format( TimeInterval::$iso_datetime_format ); $date_created_gmt = new \DateTime( $date_created ); $date_created_gmt->setTimezone( $gmt_timzone ); $date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format ); } else { $date_created = ''; $date_created_gmt = ''; } $extended_info = array( 'code' => $coupon->get_code(), 'date_created' => $date_created, 'date_created_gmt' => $date_created_gmt, 'date_expires' => $date_expires, 'date_expires_gmt' => $date_expires_gmt, 'discount_type' => $coupon->get_discount_type(), ); } } $coupon_data[ $idx ]['extended_info'] = $extended_info; } } /** * Get coupon ID for an order. * * Tries to get the ID from order item meta, then falls back to a query of published coupons. * * @param \WC_Order_Item_Coupon $coupon_item The coupon order item object. * @return int Coupon ID on success, 0 on failure. */ public static function get_coupon_id( \WC_Order_Item_Coupon $coupon_item ) { // First attempt to get coupon ID from order item data. $coupon_info = $coupon_item->get_meta( 'coupon_info', true ); if ( $coupon_info ) { return json_decode( $coupon_info, true )[0]; } $coupon_data = $coupon_item->get_meta( 'coupon_data', true ); // Normal checkout orders should have this data. // See: https://github.com/woocommerce/woocommerce/blob/3dc7df7af9f7ca0c0aa34ede74493e856f276abe/includes/abstracts/abstract-wc-order.php#L1206. if ( isset( $coupon_data['id'] ) ) { return $coupon_data['id']; } // Try to get the coupon ID using the code. return wc_get_coupon_id_by_code( $coupon_item->get_code() ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'coupon_id'; $defaults['coupons'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_coupons = $this->get_included_coupons_array( $query_args ); $limit_params = $this->get_limit_params( $query_args ); $this->subquery->add_sql_clause( 'select', $selections ); $this->add_sql_query_params( $query_args ); if ( count( $included_coupons ) > 0 ) { $total_results = count( $included_coupons ); $total_pages = (int) ceil( $total_results / $limit_params['per_page'] ); $fields = $this->get_fields( $query_args ); $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); $this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.coupon_id = {$table_name}.coupon_id" ); $coupons_query = $this->get_query_statement(); } else { if ( in_array( $query_args['orderby'], array( 'amount', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', coupon_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $coupons_query = $this->subquery->get_query_statement(); $this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) ); $this->subquery->add_sql_clause( 'select', 'coupon_id' ); $coupon_subquery = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt"; $db_records_count = (int) $wpdb->get_var( $coupon_subquery // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } } $coupon_data = $wpdb->get_results( $coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $coupon_data ) { return $data; } $this->include_extended_info( $coupon_data, $query_args ); $coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data ); $data = (object) array( 'data' => $coupon_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Create or update an an entry in the wc_order_coupon_lookup table for an order. * * @since 3.5.0 * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_coupons( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } // Refunds don't affect coupon stats so return successfully if one is called here. if ( 'shop_order_refund' === $order->get_type() ) { return true; } $table_name = self::get_db_table_name(); $existing_items = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT coupon_id FROM {$table_name} WHERE order_id = %d", $order_id ) ); $existing_items = array_flip( $existing_items ); $coupon_items = $order->get_items( 'coupon' ); $coupon_items_count = count( $coupon_items ); $num_updated = 0; $num_deleted = 0; foreach ( $coupon_items as $coupon_item ) { $coupon_id = self::get_coupon_id( $coupon_item ); unset( $existing_items[ $coupon_id ] ); if ( ! $coupon_id ) { // Insert a unique, but obviously invalid ID for this deleted coupon. ++$num_deleted; $coupon_id = -1 * $num_deleted; } $result = $wpdb->replace( self::get_db_table_name(), array( 'order_id' => $order_id, 'coupon_id' => $coupon_id, 'discount_amount' => $coupon_item->get_discount(), 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), ), array( '%d', '%d', '%f', '%s', ) ); /** * Fires when coupon's reports are updated. * * @param int $coupon_id Coupon ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_coupon', $coupon_id, $order_id ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } if ( ! empty( $existing_items ) ) { $existing_items = array_flip( $existing_items ); $format = array_fill( 0, count( $existing_items ), '%d' ); $format = implode( ',', $format ); array_unshift( $existing_items, $order_id ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$table_name} WHERE order_id = %d AND coupon_id in ({$format})", $existing_items ) ); } return ( $coupon_items_count === $num_updated ); } /** * Clean coupons data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when coupon's reports are removed from database. * * @param int $coupon_id Coupon ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_coupon', 0, $order_id ); ReportsCache::invalidate(); } /** * Gets coupons based on the provided arguments. * * @todo Upon core merge, including this in core's `class-wc-coupon-data-store-cpt.php` might make more sense. * @param array $args Array of args to filter the query by. Supports `include`. * @return array Array of results. */ public function get_coupons( $args ) { global $wpdb; $query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type='shop_coupon'"; $included_coupons = $this->get_included_coupons( $args, 'include' ); if ( ! empty( $included_coupons ) ) { $query .= " AND ID IN ({$included_coupons})"; } return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'coupon_id' ); } } Reports/Coupons/Controller.php 0000777 00000017765 15252174340 0012516 0 ustar 00 <?php /** * REST API Reports coupons controller * * Handles requests to the /reports/coupons endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports coupons controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/coupons'; /** * Get data from `'coupons'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'coupons' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['coupons'] = (array) $request['coupons']; $args['extended_info'] = $request['extended_info']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_coupons', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'coupon' => array( 'href' => rest_url( sprintf( '/%s/coupons/%d', $this->namespace, $object['coupon_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_coupons', 'type' => 'object', 'properties' => array( 'coupon_id' => array( 'description' => __( 'Coupon ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'amount' => array( 'description' => __( 'Net discount amount.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'code' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon code.', 'woocommerce' ), ), 'date_created' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon creation date.', 'woocommerce' ), ), 'date_created_gmt' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon creation date in GMT.', 'woocommerce' ), ), 'date_expires' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon expiration date.', 'woocommerce' ), ), 'date_expires_gmt' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon expiration date in GMT.', 'woocommerce' ), ), 'discount_type' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'enum' => array_keys( wc_get_coupon_types() ), 'description' => __( 'Coupon discount type.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'coupon_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'coupon_id', 'code', 'amount', 'orders_count', ) ); $params['coupons'] = array( 'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'code' => __( 'Coupon code', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'amount' => __( 'Amount discounted', 'woocommerce' ), 'created' => __( 'Created', 'woocommerce' ), 'expires' => __( 'Expires', 'woocommerce' ), 'type' => __( 'Type', 'woocommerce' ), ); /** * Filter to add or remove column names from the coupons report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_coupons_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $date_expires = empty( $item['extended_info']['date_expires'] ) ? __( 'N/A', 'woocommerce' ) : $item['extended_info']['date_expires']; $export_item = array( 'code' => $item['extended_info']['code'], 'orders_count' => $item['orders_count'], 'amount' => $item['amount'], 'created' => $item['extended_info']['date_created'], 'expires' => $date_expires, 'type' => $item['extended_info']['discount_type'], ); /** * Filter to prepare extra columns in the export item for the coupons * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_coupons_prepare_export_item', $export_item, $item ); } } Reports/Coupons/Stats/Query.php 0000777 00000003574 15252174340 0012567 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'coupons' => array(5, 120), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Stats\Query * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_coupons_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args ); } } Reports/Coupons/Stats/Controller.php 0000777 00000011741 15252174340 0013600 0 ustar 00 <?php /** * REST API Reports coupons stats controller * * Handles requests to the /reports/coupons/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports coupons stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/coupons/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['coupons'] = (array) $request['coupons']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'coupons-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'coupons-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_coupons_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'amount' => array( 'description' => __( 'Net discount amount.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'coupons_count' => array( 'description' => __( 'Number of coupons.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'title' => __( 'Discounted orders', 'woocommerce' ), 'description' => __( 'Number of discounted orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_coupons_stats'; return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'amount', 'coupons_count', 'orders_count', ) ); $params['coupons'] = array( 'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'variation', 'category', 'coupon', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } Reports/Coupons/Stats/DataStore.php 0000777 00000020213 15252174340 0013335 0 ustar 00 <?php /** * API\Reports\Coupons\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Coupons\Stats\DataStore. */ class DataStore extends CouponsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override CouponsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'date_start_gmt' => 'strval', 'date_end_gmt' => 'strval', 'amount' => 'floatval', 'coupons_count' => 'intval', 'orders_count' => 'intval', ); /** * SQL columns to select in the db query. * * @override CouponsDataStore::$report_columns * * @var array */ protected $report_columns; /** * Data store context used to pass to filters. * * @override CouponsDataStore::$context * * @var string */ protected $context = 'coupons_stats'; /** * Cache identifier. * * @override CouponsDataStore::get_default_query_vars() * * @var string */ protected $cache_key = 'coupons_stats'; /** * Assign report columns once full table name has been assigned. * * @override CouponsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'amount' => 'SUM(discount_amount) as amount', 'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $clauses = array( 'where' => '', 'join' => '', ); $order_coupon_lookup_table = self::get_db_table_name(); $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { $clauses['where'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $clauses['join'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $clauses['where'] .= " AND ( {$order_status_filter} )"; } $this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table ); $this->add_intervals_sql_params( $query_args, $order_coupon_lookup_table ); $clauses['where_time'] = $this->get_sql_clause( 'where_time' ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) ); $this->interval_query->add_sql_clause( 'select', 'AS time_interval' ); foreach ( array( 'join', 'where_time', 'where' ) as $clause ) { $this->interval_query->add_sql_clause( $clause, $clauses[ $clause ] ); $this->total_query->add_sql_clause( $clause, $clauses[ $clause ] ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override CouponsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['coupons'] = array(); $defaults['interval'] = 'week'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override CouponsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $totals_query = array(); $intervals_query = array(); $limit_params = $this->get_limit_sql_params( $query_args ); $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->total_query->add_sql_clause( 'select', $selections ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return $data; } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); // Intervals. $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return $data; } $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } } Reports/Coupons/Stats/Segmenter.php 0000777 00000036274 15252174340 0013416 0 ustar 00 <?php /** * Class for adding segmenting support to coupons/stats without cluttering the data store. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. coupon discount amount for product X when segmenting by product id or category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'amount' => "SUM($products_table.coupon_amount) as amount", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-related product-level segmenting query * (e.g. orders_count when segmented by category). * * @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $coupons_lookup_table ) { $columns_mapping = array( 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-level segmenting query * (e.g. discount amount when segmented by coupons). * * @param string $coupons_lookup_table Name of SQL table containing the order-level info. * @param array $overrides Array of overrides for default column calculations. * * @return array Column => SELECT query mapping. */ protected function segment_selections_orders( $coupons_lookup_table, $overrides = array() ) { $columns_mapping = array( 'amount' => "SUM($coupons_lookup_table.discount_amount) as amount", 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", ); if ( $overrides ) { $columns_mapping = array_merge( $columns_mapping, $overrides ); } return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; // Product-level numbers and order-level numbers can be fetched by the same query. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} {$segmenting_selections['order_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; // LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments. $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); // Product-level numbers and order-level numbers can be fetched by the same query. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} {$segmenting_selections['order_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segments = null; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = ''; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = " INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id) LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { $coupon_level_columns = $this->segment_selections_orders( $table_name ); $segmenting_selections = $this->prepare_selections( $coupon_level_columns ); $this->report_columns = $coupon_level_columns; $segmenting_from = ''; $segmenting_groupby = "$table_name.coupon_id"; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } Reports/Coupons/Query.php 0000777 00000003513 15252174340 0011462 0 ustar 00 <?php /** * Class for parameter-based Coupons Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'coupons' => array(5, 120), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Query * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_coupons_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args ); } } Reports/SqlQuery.php 0000777 00000012236 15252174340 0010516 0 ustar 00 <?php /** * Admin\API\Reports\SqlQuery class file. */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Admin\API\Reports\SqlQuery: Common parent for manipulating SQL query clauses. */ class SqlQuery { /** * List of SQL clauses. * * @var array */ private $sql_clauses = array( 'select' => array(), 'from' => array(), 'left_join' => array(), 'join' => array(), 'right_join' => array(), 'where' => array(), 'where_time' => array(), 'group_by' => array(), 'having' => array(), 'limit' => array(), 'order_by' => array(), 'union' => array(), ); /** * SQL clause merge filters. * * @var array */ private $sql_filters = array( 'where' => array( 'where', 'where_time', ), 'join' => array( 'right_join', 'join', 'left_join', ), ); /** * Data store context used to pass to filters. * * @var string */ protected $context; /** * Constructor. * * @param string $context Optional context passed to filters. Default empty string. */ public function __construct( $context = '' ) { $this->context = $context; } /** * Add a SQL clause to be included when get_data is called. * * @param string $type Clause type. * @param string $clause SQL clause. */ public function add_sql_clause( $type, $clause ) { if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) { $this->sql_clauses[ $type ][] = $clause; } } /** * Get SQL clause by type. * * @param string $type Clause type. * @param string $handling Whether to filter the return value (filtered|unfiltered). Default unfiltered. * * @return string SQL clause. */ protected function get_sql_clause( $type, $handling = 'unfiltered' ) { if ( ! isset( $this->sql_clauses[ $type ] ) ) { return ''; } /** * Default to bypassing filters for clause retrieval internal to data stores. * The filters are applied when the full SQL statement is retrieved. */ if ( 'unfiltered' === $handling ) { return implode( ' ', $this->sql_clauses[ $type ] ); } if ( isset( $this->sql_filters[ $type ] ) ) { $clauses = array(); foreach ( $this->sql_filters[ $type ] as $subset ) { $clauses = array_merge( $clauses, $this->sql_clauses[ $subset ] ); } } else { $clauses = $this->sql_clauses[ $type ]; } /** * Filter SQL clauses by type and context. * * @param array $clauses The original arguments for the request. * @param string $context The data store context. */ $clauses = apply_filters( "woocommerce_analytics_clauses_{$type}", $clauses, $this->context ); /** * Filter SQL clauses by type and context. * * @param array $clauses The original arguments for the request. */ $clauses = apply_filters( "woocommerce_analytics_clauses_{$type}_{$this->context}", $clauses ); return implode( ' ', $clauses ); } /** * Clear SQL clauses by type. * * @param string|array $types Clause type. */ protected function clear_sql_clause( $types ) { foreach ( (array) $types as $type ) { if ( isset( $this->sql_clauses[ $type ] ) ) { $this->sql_clauses[ $type ] = array(); } } } /** * Replace strings within SQL clauses by type. * * @param string $type Clause type. * @param string $search String to search for. * @param string $replace Replacement string. */ protected function str_replace_clause( $type, $search, $replace ) { if ( isset( $this->sql_clauses[ $type ] ) ) { foreach ( $this->sql_clauses[ $type ] as $key => $sql ) { $this->sql_clauses[ $type ][ $key ] = str_replace( $search, $replace, $sql ); } } } /** * Get the full SQL statement. * * @return string */ public function get_query_statement() { $join = $this->get_sql_clause( 'join', 'filtered' ); $where = $this->get_sql_clause( 'where', 'filtered' ); $group_by = $this->get_sql_clause( 'group_by', 'filtered' ); $having = $this->get_sql_clause( 'having', 'filtered' ); $order_by = $this->get_sql_clause( 'order_by', 'filtered' ); $union = $this->get_sql_clause( 'union', 'filtered' ); $statement = ''; $statement .= " SELECT {$this->get_sql_clause( 'select', 'filtered' )} FROM {$this->get_sql_clause( 'from', 'filtered' )} {$join} WHERE 1=1 {$where} "; if ( ! empty( $group_by ) ) { $statement .= " GROUP BY {$group_by} "; if ( ! empty( $having ) ) { $statement .= " HAVING 1=1 {$having} "; } } if ( ! empty( $union ) ) { $statement .= " UNION {$union} "; } if ( ! empty( $order_by ) ) { $statement .= " ORDER BY {$order_by} "; } return $statement . $this->get_sql_clause( 'limit', 'filtered' ); } /** * Reinitialize the clause array. */ public function clear_all_clauses() { $this->sql_clauses = array( 'select' => array(), 'from' => array(), 'left_join' => array(), 'join' => array(), 'right_join' => array(), 'where' => array(), 'where_time' => array(), 'group_by' => array(), 'having' => array(), 'limit' => array(), 'order_by' => array(), 'union' => array(), ); } } Reports/TimeInterval.php 0000777 00000056743 15252174340 0011347 0 ustar 00 <?php /** * Class for time interval and numeric range handling for reports. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * Date & time interval and numeric range handling class for Reporting API. */ class TimeInterval { /** * Format string for ISO DateTime formatter. * * @var string */ public static $iso_datetime_format = 'Y-m-d\TH:i:s'; /** * Format string for use in SQL queries. * * @var string */ public static $sql_datetime_format = 'Y-m-d H:i:s'; /** * Converts local datetime to GMT/UTC time. * * @param string $datetime_string String representation of local datetime. * @return DateTime */ public static function convert_local_datetime_to_gmt( $datetime_string ) { $datetime = new \DateTime( $datetime_string, new \DateTimeZone( wc_timezone_string() ) ); $datetime->setTimezone( new \DateTimeZone( 'GMT' ) ); return $datetime; } /** * Returns default 'before' parameter for the reports. * * @return DateTime */ public static function default_before() { $datetime = new \WC_DateTime(); // Set local timezone or offset. if ( get_option( 'timezone_string' ) ) { $datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); } else { $datetime->set_utc_offset( wc_timezone_offset() ); } return $datetime; } /** * Returns default 'after' parameter for the reports. * * @return DateTime */ public static function default_after() { $now = time(); $week_back = $now - WEEK_IN_SECONDS; $datetime = new \WC_DateTime(); $datetime->setTimestamp( $week_back ); // Set local timezone or offset. if ( get_option( 'timezone_string' ) ) { $datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); } else { $datetime->set_utc_offset( wc_timezone_offset() ); } return $datetime; } /** * Returns date format to be used as grouping clause in SQL. * * @param string $time_interval Time interval. * @param string $table_name Name of the db table relevant for the date constraint. * @param string $date_column_name Name of the date table column. * @return mixed */ public static function db_datetime_format( $time_interval, $table_name, $date_column_name = 'date_created' ) { $first_day_of_week = absint( get_option( 'start_of_week' ) ); if ( 1 === $first_day_of_week ) { // Week begins on Monday, ISO 8601. $week_format = "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%x-%v')"; } else { // Week begins on day other than specified by ISO 8601, needs to be in sync with function simple_week_number. $week_format = "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', LPAD( FLOOR( ( DAYOFYEAR({$table_name}.`{$date_column_name}`) + ( ( DATE_FORMAT(MAKEDATE(YEAR({$table_name}.`{$date_column_name}`),1), '%w') - $first_day_of_week + 7 ) % 7 ) - 1 ) / 7 ) + 1 , 2, '0'))"; } // Whenever this is changed, double check method time_interval_id to make sure they are in sync. $mysql_date_format_mapping = array( 'hour' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d %H')", 'day' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d')", 'week' => $week_format, 'month' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m')", 'quarter' => "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', QUARTER({$table_name}.`{$date_column_name}`))", 'year' => "YEAR({$table_name}.`{$date_column_name}`)", ); return $mysql_date_format_mapping[ $time_interval ]; } /** * Returns quarter for the DateTime. * * @param DateTime $datetime Local date & time. * @return int|null */ public static function quarter( $datetime ) { switch ( (int) $datetime->format( 'm' ) ) { case 1: case 2: case 3: return 1; case 4: case 5: case 6: return 2; case 7: case 8: case 9: return 3; case 10: case 11: case 12: return 4; } return null; } /** * Returns simple week number for the DateTime, for week starting on $first_day_of_week. * * The first week of the year is considered to be the week containing January 1. * The second week starts on the next $first_day_of_week. * * @param DateTime $datetime Local date for which the week number is to be calculated. * @param int $first_day_of_week 0 for Sunday to 6 for Saturday. * @return int */ public static function simple_week_number( $datetime, $first_day_of_week ) { $beg_of_year_day = new \DateTime( "{$datetime->format('Y')}-01-01" ); $adj_day_beg_of_year = ( (int) $beg_of_year_day->format( 'w' ) - $first_day_of_week + 7 ) % 7; $days_since_start_of_year = (int) $datetime->format( 'z' ) + 1; return (int) floor( ( ( $days_since_start_of_year + $adj_day_beg_of_year - 1 ) / 7 ) ) + 1; } /** * Returns ISO 8601 week number for the DateTime, if week starts on Monday, * otherwise returns simple week number. * * @see TimeInterval::simple_week_number() * * @param DateTime $datetime Local date for which the week number is to be calculated. * @param int $first_day_of_week 0 for Sunday to 6 for Saturday. * @return int */ public static function week_number( $datetime, $first_day_of_week ) { if ( 1 === $first_day_of_week ) { $week_number = (int) $datetime->format( 'W' ); } else { $week_number = self::simple_week_number( $datetime, $first_day_of_week ); } return $week_number; } /** * Returns time interval id for the DateTime. * * @param string $time_interval Time interval type (week, day, etc). * @param DateTime $datetime Date & time. * @return string */ public static function time_interval_id( $time_interval, $datetime ) { // Whenever this is changed, double check method db_datetime_format to make sure they are in sync. $php_time_format_for = array( 'hour' => 'Y-m-d H', 'day' => 'Y-m-d', 'week' => 'o-W', 'month' => 'Y-m', 'quarter' => 'Y-' . self::quarter( $datetime ), 'year' => 'Y', ); // If the week does not begin on Monday. $first_day_of_week = absint( get_option( 'start_of_week' ) ); if ( 'week' === $time_interval && 1 !== $first_day_of_week ) { $week_no = self::simple_week_number( $datetime, $first_day_of_week ); $week_no = str_pad( $week_no, 2, '0', STR_PAD_LEFT ); $year_no = $datetime->format( 'Y' ); return "$year_no-$week_no"; } return $datetime->format( $php_time_format_for[ $time_interval ] ); } /** * Calculates number of time intervals between two dates, closed interval on both sides. * * @param DateTime $start_datetime Start date & time. * @param DateTime $end_datetime End date & time. * @param string $interval Time interval increment, e.g. hour, day, week. * * @return int */ public static function intervals_between( $start_datetime, $end_datetime, $interval ) { switch ( $interval ) { case 'hour': $end_timestamp = (int) $end_datetime->format( 'U' ); $start_timestamp = (int) $start_datetime->format( 'U' ); $addendum = 0; // modulo HOUR_IN_SECONDS would normally work, but there are non-full hour timezones, e.g. Nepal. $start_min_sec = (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' ); $end_min_sec = (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' ); if ( $end_min_sec < $start_min_sec ) { $addendum = 1; } $diff_timestamp = $end_timestamp - $start_timestamp; return (int) floor( ( (int) $diff_timestamp ) / HOUR_IN_SECONDS ) + 1 + $addendum; case 'day': $days = $start_datetime->diff( $end_datetime )->format( '%r%a' ); $end_hour_min_sec = (int) $end_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' ); $start_hour_min_sec = (int) $start_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' ); if ( $end_hour_min_sec < $start_hour_min_sec ) { $days++; } return $days + 1; case 'week': // @todo Optimize? approximately day count / 7, but year end is tricky, a week can have fewer days. $week_count = 0; do { $start_datetime = self::next_week_start( $start_datetime ); $week_count++; } while ( $start_datetime <= $end_datetime ); return $week_count; case 'month': // Year diff in months: (end_year - start_year - 1) * 12. $year_diff_in_months = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 12; // All the months in end_date year plus months from X to 12 in the start_date year. $month_diff = (int) $end_datetime->format( 'n' ) + ( 12 - (int) $start_datetime->format( 'n' ) ); // Add months for number of years between end_date and start_date. $month_diff += $year_diff_in_months + 1; return $month_diff; case 'quarter': // Year diff in quarters: (end_year - start_year - 1) * 4. $year_diff_in_quarters = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 4; // All the quarters in end_date year plus quarters from X to 4 in the start_date year. $quarter_diff = self::quarter( $end_datetime ) + ( 4 - self::quarter( $start_datetime ) ); // Add quarters for number of years between end_date and start_date. $quarter_diff += $year_diff_in_quarters + 1; return $quarter_diff; case 'year': $year_diff = (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ); return $year_diff + 1; } return 0; } /** * Returns a new DateTime object representing the next hour start/previous hour end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_hour_start( $datetime, $reversed = false ) { $hour_increment = $reversed ? 0 : 1; $timestamp = (int) $datetime->format( 'U' ); $seconds_into_hour = (int) $datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $datetime->format( 's' ); $hours_offset_timestamp = $timestamp + ( $hour_increment * HOUR_IN_SECONDS - $seconds_into_hour ); if ( $reversed ) { $hours_offset_timestamp --; } $hours_offset_time = new \DateTime(); $hours_offset_time->setTimestamp( $hours_offset_timestamp ); $hours_offset_time->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); return $hours_offset_time; } /** * Returns a new DateTime object representing the next day start, or previous day end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_day_start( $datetime, $reversed = false ) { $oneday = new \DateInterval( 'P1D' ); $new_datetime = clone $datetime; if ( $reversed ) { $new_datetime->sub( $oneday ); $new_datetime->setTime( 23, 59, 59 ); } else { $new_datetime->add( $oneday ); $new_datetime->setTime( 0, 0, 0 ); } return $new_datetime; } /** * Returns DateTime object representing the next week start, or previous week end if reversed. * * The next week start is the first day of the next week at 00:00:00. * The previous week end is the last day of the previous week at 23:59:59. * The start day is determined by the "start_of_week" wp_option. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_week_start( $datetime, $reversed = false ) { $seven_days = new \DateInterval( 'P7D' ); // Default timezone set in wp-settings.php. $default_timezone = date_default_timezone_get(); // Timezone that the WP site uses in Settings > General. $original_timezone = $datetime->getTimezone(); // @codingStandardsIgnoreStart date_default_timezone_set( 'UTC' ); $start_end_timestamp = get_weekstartend( $datetime->format( 'Y-m-d' ) ); date_default_timezone_set( $default_timezone ); // @codingStandardsIgnoreEnd if ( $reversed ) { $result = \DateTime::createFromFormat( 'U', $start_end_timestamp['end'] )->sub( $seven_days ); } else { $result = \DateTime::createFromFormat( 'U', $start_end_timestamp['start'] )->add( $seven_days ); } return \DateTime::createFromFormat( 'Y-m-d H:i:s', $result->format( 'Y-m-d H:i:s' ), $original_timezone ); } /** * Returns a new DateTime object representing the next month start, or previous month end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_month_start( $datetime, $reversed = false ) { $month_increment = 1; $year = $datetime->format( 'Y' ); $month = (int) $datetime->format( 'm' ); if ( $reversed ) { $beg_of_month_datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); $timestamp = (int) $beg_of_month_datetime->format( 'U' ); $end_of_prev_month_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_month_timestamp ); } else { $month += $month_increment; if ( $month > 12 ) { $month = 1; $year ++; } $day = '01'; $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); } return $datetime; } /** * Returns a new DateTime object representing the next quarter start, or previous quarter end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_quarter_start( $datetime, $reversed = false ) { $year = $datetime->format( 'Y' ); $month = (int) $datetime->format( 'n' ); switch ( $month ) { case 1: case 2: case 3: if ( $reversed ) { $month = 1; } else { $month = 4; } break; case 4: case 5: case 6: if ( $reversed ) { $month = 4; } else { $month = 7; } break; case 7: case 8: case 9: if ( $reversed ) { $month = 7; } else { $month = 10; } break; case 10: case 11: case 12: if ( $reversed ) { $month = 10; } else { $month = 1; $year ++; } break; } $datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); if ( $reversed ) { $timestamp = (int) $datetime->format( 'U' ); $end_of_prev_month_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_month_timestamp ); } return $datetime; } /** * Return a new DateTime object representing the next year start, or previous year end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_year_start( $datetime, $reversed = false ) { $year_increment = 1; $year = (int) $datetime->format( 'Y' ); $month = '01'; $day = '01'; if ( $reversed ) { $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); $timestamp = (int) $datetime->format( 'U' ); $end_of_prev_year_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_year_timestamp ); } else { $year += $year_increment; $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); } return $datetime; } /** * Returns beginning of next time interval for provided DateTime. * * E.g. for current DateTime, beginning of next day, week, quarter, etc. * * @param DateTime $datetime Date and time. * @param string $time_interval Time interval, e.g. week, day, hour. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function iterate( $datetime, $time_interval, $reversed = false ) { return call_user_func( array( __CLASS__, "next_{$time_interval}_start" ), $datetime, $reversed ); } /** * Returns expected number of items on the page in case of date ordering. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * * @return float|int */ public static function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) { $total_pages = (int) ceil( $expected_interval_count / $items_per_page ); if ( $page_no < $total_pages ) { return $items_per_page; } elseif ( $page_no === $total_pages ) { return $expected_interval_count - ( $page_no - 1 ) * $items_per_page; } else { return 0; } } /** * Returns true if there are any intervals that need to be filled in the response. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $db_records Total number of records for given period in the database. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * @param string $order asc or desc. * @param string $order_by Column by which the result will be sorted. * @param int $intervals_count Number of records for given (possibly shortened) time interval. * * @return bool */ public static function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) { if ( $expected_interval_count <= $db_records ) { return false; } if ( 'date' === $order_by ) { $expected_intervals_on_page = self::expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ); return $intervals_count < $expected_intervals_on_page; } if ( 'desc' === $order ) { return $page_no > floor( $db_records / $items_per_page ); } if ( 'asc' === $order ) { return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page ); } // Invalid ordering. return false; } /** * Normalize "*_between" parameters to "*_min" and "*_max" for numeric values * and "*_after" and "*_before" for date values. * * @param array $request Query params from REST API request. * @param string|array $param_names One or more param names to handle. Should not include "_between" suffix. * @param bool $is_date Boolean if the param is date is related. * @return array Normalized query values. */ public static function normalize_between_params( $request, $param_names, $is_date ) { if ( ! is_array( $param_names ) ) { $param_names = array( $param_names ); } $normalized = array(); foreach ( $param_names as $param_name ) { if ( ! is_array( $request[ $param_name . '_between' ] ) ) { continue; } $range = $request[ $param_name . '_between' ]; if ( 2 !== count( $range ) ) { continue; } $min = $is_date ? '_after' : '_min'; $max = $is_date ? '_before' : '_max'; if ( $range[0] < $range[1] ) { $normalized[ $param_name . $min ] = $range[0]; $normalized[ $param_name . $max ] = $range[1]; } else { $normalized[ $param_name . $min ] = $range[1]; $normalized[ $param_name . $max ] = $range[0]; } } return $normalized; } /** * Validate a "*_between" range argument (an array with 2 numeric items). * * @param mixed $value Parameter value. * @param WP_REST_Request $request REST Request. * @param string $param Parameter name. * @return WP_Error|boolean */ public static function rest_validate_between_numeric_arg( $value, $request, $param ) { if ( ! wp_is_numeric_array( $value ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: 1: parameter name */ sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param ) ); } if ( ! is_array( $value ) || 2 !== count( $value ) || ! is_numeric( $value[0] ) || ! is_numeric( $value[1] ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: parameter name */ sprintf( __( '%s must contain 2 numbers.', 'woocommerce' ), $param ) ); } return true; } /** * Validate a "*_between" range argument (an array with 2 date items). * * @param mixed $value Parameter value. * @param WP_REST_Request $request REST Request. * @param string $param Parameter name. * @return WP_Error|boolean */ public static function rest_validate_between_date_arg( $value, $request, $param ) { if ( ! wp_is_numeric_array( $value ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: 1: parameter name */ sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param ) ); } if ( ! is_array( $value ) || 2 !== count( $value ) || ! rest_parse_date( $value[0] ) || ! rest_parse_date( $value[1] ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: parameter name */ sprintf( __( '%s must contain 2 valid dates.', 'woocommerce' ), $param ) ); } return true; } /** * Get dates from a timeframe string. * * @param int $timeframe Timeframe to use. One of: last_week|last_month|last_quarter|last_6_months|last_year. * @param DateTime|null $current_date DateTime of current date to compare. * @return array */ public static function get_timeframe_dates( $timeframe, $current_date = null ) { if ( ! $current_date ) { $current_date = new \DateTime(); } $current_year = $current_date->format( 'Y' ); $current_month = $current_date->format( 'm' ); if ( 'last_week' === $timeframe ) { return array( 'start' => $current_date->modify( 'last week monday' )->format( 'Y-m-d 00:00:00' ), 'end' => $current_date->modify( 'this sunday' )->format( 'Y-m-d 23:59:59' ), ); } if ( 'last_month' === $timeframe ) { return array( 'start' => $current_date->modify( 'first day of previous month' )->format( 'Y-m-d 00:00:00' ), 'end' => $current_date->modify( 'last day of this month' )->format( 'Y-m-d 23:59:59' ), ); } if ( 'last_quarter' === $timeframe ) { switch ( $current_month ) { case $current_month >= 1 && $current_month <= 3: return array( 'start' => ( $current_year - 1 ) . '-10-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); case $current_month >= 4 && $current_month <= 6: return array( 'start' => $current_year . '-01-01 00:00:00', 'end' => $current_year . '-03-31 23:59:59', ); case $current_month >= 7 && $current_month <= 9: return array( 'start' => $current_year . '-04-01 00:00:00', 'end' => $current_year . '-06-30 23:59:59', ); case $current_month >= 10 && $current_month <= 12: return array( 'start' => $current_year . '-07-01 00:00:00', 'end' => $current_year . '-09-31 23:59:59', ); } } if ( 'last_6_months' === $timeframe ) { if ( $current_month >= 1 && $current_month <= 6 ) { return array( 'start' => ( $current_year - 1 ) . '-07-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); } return array( 'start' => $current_year . '-01-01 00:00:00', 'end' => $current_year . '-06-30 23:59:59', ); } if ( 'last_year' === $timeframe ) { return array( 'start' => ( $current_year - 1 ) . '-01-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); } return false; } } Reports/StatsDataStoreTrait.php 0000777 00000011407 15252174340 0012641 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * Trait to contain *stats-specific methods for data stores. * * It does preliminary intervals & page calculations * and prepares intervals & totals data structure by implementing the `get_noncached_data()` method. * So, this time, you'll need to prepare `get_noncached_stats_data()` which will be called only if * the requested page is within the date range. * * The trait also exposes the `initialize_queries()` method to initialize the interval and total queries. * * Example: * <pre><code class="language-php">class MyStatsDataStore extends DataStore implements DataStoreInterface { * // Use the trait. * use StatsDataStoreTrait; * // Provide all the necessary properties and methods for a regular DataStore. * // ... * /** * * Return your results with the help of the interval & total methods and queries. * * @return stdClass|WP_Error $data filled with your results. * */ * public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { * $this->initialize_queries(); * // Do your magic ... * // ... with a help of things like: * $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); * $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); * * $totals = $wpdb->get_results( * $this->total_query->get_query_statement(), * ARRAY_A * ); * * $intervals = $wpdb->get_results( * $this->interval_query->get_query_statement(), * ARRAY_A * ); * * $data->totals = (object) $this->cast_numbers( $totals[0] ); * $data->intervals = $intervals; * * if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { * $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); * $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); * $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); * } else { * $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); * } * * return $data; * } * } * </code></pre> * * @see DataStore */ trait StatsDataStoreTrait { /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); unset( $this->subquery ); $table_name = self::get_db_table_name(); $this->total_query = new SqlQuery( $this->context . '_total' ); $this->total_query->add_sql_clause( 'from', $table_name ); $this->interval_query = new SqlQuery( $this->context . '_interval' ); $this->interval_query->add_sql_clause( 'from', $table_name ); $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); } /** * Returns the stats report data based on normalized parameters. * Prepares the basic intervals and object structure * Will be called by `get_data` if there is no data in cache. * Will call `get_noncached_stats_data` to fetch the actual data. * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object, or error. */ public function get_noncached_data( $query_args ) { $params = $this->get_limit_params( $query_args ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); // Default, empty data object. $data = (object) array( 'totals' => null, 'intervals' => array(), 'total' => $expected_interval_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); // If the requested page is out off range, return the default empty object. if ( $query_args['page'] >= 1 && $query_args['page'] <= $total_pages ) { // Fetch the actual data. $data = $this->get_noncached_stats_data( $query_args, $params, $data, $expected_interval_count ); if ( ! is_wp_error( $data ) && is_array( $data->intervals ) ) { $this->create_interval_subtotals( $data->intervals ); } } return $data; } } Reports/Segmenter.php 0000777 00000062656 15252174340 0010675 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxesStatsDataStore; use Automattic\WooCommerce\Enums\ProductType; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter { /** * Array of all segment ids. * * @var array|bool */ protected $all_segment_ids = false; /** * Array of all segment labels. * * @var array */ protected $segment_labels = array(); /** * Query arguments supplied by the user for data store. * * @var array */ protected $query_args = ''; /** * SQL definition for each column. * * @var array */ protected $report_columns = array(); /** * Constructor. * * @param array $query_args Query arguments supplied by the user for data store. * @param array $report_columns Report columns lookup from data store. */ public function __construct( $query_args, $report_columns ) { $this->query_args = $query_args; $this->report_columns = $report_columns; } /** * Filters definitions for SELECT clauses based on query_args and joins them into one string usable in SELECT clause. * * @param array $columns_mapping Column name -> SQL statememt mapping. * * @return string to be used in SELECT clause statements. */ protected function prepare_selections( $columns_mapping ) { if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) { $keep = array(); foreach ( $this->query_args['fields'] as $field ) { if ( isset( $columns_mapping[ $field ] ) ) { $keep[ $field ] = $columns_mapping[ $field ]; } } $selections = implode( ', ', $keep ); } else { $selections = implode( ', ', $columns_mapping ); } if ( $selections ) { $selections = ',' . $selections; } return $selections; } /** * Update row-level db result for segments in 'totals' section to the format used for output. * * @param array $segments_db_result Results from the SQL db query for segmenting. * @param string $segment_dimension Name of column used for grouping the result. * * @return array Reformatted array. */ protected function reformat_totals_segments( $segments_db_result, $segment_dimension ) { $segment_result = array(); if ( strpos( $segment_dimension, '.' ) ) { $segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 ); } $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_id' => $segment_id, 'segment_label' => $segment_labels[ $segment_id ], 'subtotals' => $segment_data, ); $segment_result[ $segment_id ] = $segment_datum; } return $segment_result; } /** * Merges segmented results for totals response part. * * E.g. $r1 = array( * 0 => array( * 'product_id' => 3, * 'net_amount' => 15, * ), * ); * $r2 = array( * 0 => array( * 'product_id' => 3, * 'avg_order_value' => 25, * ), * ); * * $merged = array( * 3 => array( * 'segment_id' => 3, * 'subtotals' => array( * 'net_amount' => 15, * 'avg_order_value' => 25, * ) * ), * ); * * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets. * @param array $result1 Array 1 of segmented figures. * @param array $result2 Array 2 of segmented figures. * * @return array */ protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) { $result_segments = array(); $segment_labels = $this->get_segment_labels(); foreach ( $result1 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); $result_segments[ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); } foreach ( $result2 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); if ( ! isset( $result_segments[ $segment_id ] ) ) { $result_segments[ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => array(), ); } $result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data ); } return $result_segments; } /** * Merges segmented results for intervals response part. * * E.g. $r1 = array( * 0 => array( * 'product_id' => 3, * 'time_interval' => '2018-12' * 'net_amount' => 15, * ), * ); * $r2 = array( * 0 => array( * 'product_id' => 3, * 'time_interval' => '2018-12' * 'avg_order_value' => 25, * ), * ); * * $merged = array( * '2018-12' => array( * 'segments' => array( * 3 => array( * 'segment_id' => 3, * 'subtotals' => array( * 'net_amount' => 15, * 'avg_order_value' => 25, * ), * ), * ), * ), * ); * * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets. * @param array $result1 Array 1 of segmented figures. * @param array $result2 Array 2 of segmented figures. * * @return array */ protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) { $result_segments = array(); $segment_labels = $this->get_segment_labels(); foreach ( $result1 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $result_segments[ $time_interval ] ) ) { $result_segments[ $time_interval ] = array(); $result_segments[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); $result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum; } foreach ( $result2 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $result_segments[ $time_interval ] ) ) { $result_segments[ $time_interval ] = array(); $result_segments[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) { $result_segments[ $time_interval ]['segments'][ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => array(), ); } $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data ); } return $result_segments; } /** * Update row-level db result for segments in 'intervals' section to the format used for output. * * @param array $segments_db_result Results from the SQL db query for segmenting. * @param string $segment_dimension Name of column used for grouping the result. * * @return array Reformatted array. */ protected function reformat_intervals_segments( $segments_db_result, $segment_dimension ) { $aggregated_segment_result = array(); if ( strpos( $segment_dimension, '.' ) ) { $segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 ); } $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) { $aggregated_segment_result[ $time_interval ] = array(); $aggregated_segment_result[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); $aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum; } return $aggregated_segment_result; } /** * Fetches all segment ids from db and stores it for later use. * * @return void */ protected function set_all_segments() { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { $this->all_segment_ids = array(); return; } $segments = array(); $segment_labels = array(); if ( 'product' === $this->query_args['segmentby'] ) { $args = array( 'return' => 'objects', 'limit' => -1, ); if ( isset( $this->query_args['product_includes'] ) ) { $args['include'] = $this->query_args['product_includes']; } if ( isset( $this->query_args['category_includes'] ) ) { $categories = $this->query_args['category_includes']; $args['category'] = array(); foreach ( $categories as $category_id ) { $terms = get_term_by( 'id', $category_id, 'product_cat' ); $args['category'][] = $terms->slug; } } $segment_objects = wc_get_products( $args ); foreach ( $segment_objects as $segment ) { $id = $segment->get_id(); $segments[] = $id; $segment_labels[ $id ] = $segment->get_name(); } } elseif ( 'variation' === $this->query_args['segmentby'] ) { $args = array( 'return' => 'objects', 'limit' => -1, 'type' => ProductType::VARIATION, ); if ( isset( $this->query_args['product_includes'] ) && is_array( $this->query_args['product_includes'] ) && count( $this->query_args['product_includes'] ) === 1 ) { $args['parent'] = $this->query_args['product_includes'][0]; } if ( isset( $this->query_args['variation_includes'] ) ) { $args['include'] = $this->query_args['variation_includes']; } $segment_objects = wc_get_products( $args ); foreach ( $segment_objects as $segment ) { $id = $segment->get_id(); $segments[] = $id; $product_name = $segment->get_name(); $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $segment ); $attributes = wc_get_formatted_variation( $segment, true, false ); $segment_labels[ $id ] = $product_name . $separator . $attributes; } // If no variations were specified, add a segment for the parent product (variation = 0). // This is to catch simple products with prior sales converted into variable products. // See: https://github.com/woocommerce/woocommerce-admin/issues/2719. if ( isset( $args['parent'] ) && empty( $args['include'] ) ) { $parent_object = wc_get_product( $args['parent'] ); $segments[] = 0; $segment_labels[0] = $parent_object->get_name(); } } elseif ( 'category' === $this->query_args['segmentby'] ) { $args = array( 'taxonomy' => 'product_cat', ); if ( isset( $this->query_args['category_includes'] ) ) { $args['include'] = $this->query_args['category_includes']; } // @todo: Look into `wc_get_products` or data store methods and not directly touching the database or post types. $categories = get_categories( $args ); $segments = wp_list_pluck( $categories, 'cat_ID' ); $segment_labels = wp_list_pluck( $categories, 'name', 'cat_ID' ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { $args = array(); if ( isset( $this->query_args['coupons'] ) ) { $args['include'] = $this->query_args['coupons']; } $coupons_store = new CouponsDataStore(); $coupons = $coupons_store->get_coupons( $args ); $segments = wp_list_pluck( $coupons, 'ID' ); $segment_labels = wp_list_pluck( $coupons, 'post_title', 'ID' ); $segment_labels = array_map( 'wc_format_coupon_code', $segment_labels ); } elseif ( 'customer_type' === $this->query_args['segmentby'] ) { // 0 -- new customer // 1 -- returning customer $segments = array( 0, 1 ); } elseif ( 'tax_rate_id' === $this->query_args['segmentby'] ) { $args = array(); if ( isset( $this->query_args['taxes'] ) ) { $args['include'] = $this->query_args['taxes']; } $taxes = TaxesStatsDataStore::get_taxes( $args ); foreach ( $taxes as $tax ) { $id = $tax['tax_rate_id']; $segments[] = $id; $segment_labels[ $id ] = \WC_Tax::get_rate_code( (object) $tax ); } } else { // Catch all default. $segments = array(); } $this->all_segment_ids = $segments; $this->segment_labels = $segment_labels; } /** * Return all segment ids for given segmentby query parameter. * * @return array */ protected function get_all_segments() { if ( ! is_array( $this->all_segment_ids ) ) { $this->set_all_segments(); } return $this->all_segment_ids; } /** * Return all segment labels for given segmentby query parameter. * * @return array */ protected function get_segment_labels() { if ( ! is_array( $this->all_segment_ids ) ) { $this->set_all_segments(); } return $this->segment_labels; } /** * Compares two report data objects by pre-defined object property and ASC/DESC ordering. * * @param stdClass $a Object a. * @param stdClass $b Object b. * @return string */ private function segment_cmp( $a, $b ) { if ( $a['segment_id'] === $b['segment_id'] ) { return 0; } elseif ( $a['segment_id'] > $b['segment_id'] ) { return 1; } elseif ( $a['segment_id'] < $b['segment_id'] ) { return - 1; } } /** * Adds zeroes for segments not present in the data selection. * * @param array $segments Array of segments from the database for given data points. * * @return array */ protected function fill_in_missing_segments( $segments ) { $segment_subtotals = array(); if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) { foreach ( $this->query_args['fields'] as $field ) { if ( isset( $this->report_columns[ $field ] ) ) { $segment_subtotals[ $field ] = 0; } } } else { foreach ( $this->report_columns as $field => $sql_clause ) { $segment_subtotals[ $field ] = 0; } } if ( ! is_array( $segments ) ) { $segments = array(); } $all_segment_ids = $this->get_all_segments(); $segment_labels = $this->get_segment_labels(); foreach ( $all_segment_ids as $segment_id ) { if ( ! isset( $segments[ $segment_id ] ) ) { $segments[ $segment_id ] = array( 'segment_id' => $segment_id, 'segment_label' => $segment_labels[ $segment_id ], 'subtotals' => $segment_subtotals, ); } } // Using array_values to remove custom keys, so that it gets later converted to JSON as an array. $segments_no_keys = array_values( $segments ); usort( $segments_no_keys, array( $this, 'segment_cmp' ) ); return $segments_no_keys; } /** * Adds missing segments to intervals, modifies $data. * * @param stdClass $data Response data. */ protected function fill_in_missing_interval_segments( &$data ) { foreach ( $data->intervals as $order_id => $interval_data ) { $data->intervals[ $order_id ]['segments'] = $this->fill_in_missing_segments( $data->intervals[ $order_id ]['segments'] ); } } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { return array(); } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { return array(); } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { return array(); } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { return array(); } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array */ protected function get_segments( $type, $query_params, $table_name ) { return array(); } /** * Calculate segments for segmenting property bound to product (e.g. category, product_id, variation_id). * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $query_params Array of SQL clauses for intervals/totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ) { if ( 'totals' === $type ) { return $this->get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'intervals' === $type ) { return $this->get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } } /** * Calculate segments for segmenting property bound to order (e.g. coupon or customer type). * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $query_params Array of SQL clauses for intervals/totals query. * * @return array */ protected function get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ) { if ( 'totals' === $type ) { return $this->get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } elseif ( 'intervals' === $type ) { return $this->get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } } /** * Assign segments to time intervals by updating original $intervals array. * * @param array $intervals Result array from intervals SQL query. * @param array $intervals_segments Result array from interval segments SQL query. */ protected function assign_segments_to_intervals( &$intervals, $intervals_segments ) { $old_keys = array_keys( $intervals ); foreach ( $intervals as $interval ) { $intervals[ $interval['time_interval'] ] = $interval; $intervals[ $interval['time_interval'] ]['segments'] = array(); } foreach ( $old_keys as $key ) { unset( $intervals[ $key ] ); } foreach ( $intervals_segments as $time_interval => $segment ) { if ( isset( $intervals[ $time_interval ] ) ) { $intervals[ $time_interval ]['segments'] = $segment['segments']; } } // To remove time interval keys (so that REST response is formatted correctly). $intervals = array_values( $intervals ); } /** * Returns an array of segments for totals part of REST response. * * @param array $query_params Totals SQL query parameters. * @param string $table_name Name of the SQL table that is the main order stats table. * * @return array */ public function get_totals_segments( $query_params, $table_name ) { $segments = $this->get_segments( 'totals', $query_params, $table_name ); $segments = $this->fill_in_missing_segments( $segments ); return $segments; } /** * Adds an array of segments to data->intervals object. * * @param stdClass $data Data object representing the REST response. * @param array $intervals_query Intervals SQL query parameters. * @param string $table_name Name of the SQL table that is the main order stats table. */ public function add_intervals_segments( &$data, $intervals_query, $table_name ) { $intervals_segments = $this->get_segments( 'intervals', $intervals_query, $table_name ); $this->assign_segments_to_intervals( $data->intervals, $intervals_segments ); $this->fill_in_missing_interval_segments( $data ); } } Reports/Cache.php 0000777 00000002753 15252174340 0007737 0 ustar 00 <?php /** * REST API Reports Cache. * * Handles report data object caching. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * REST API Reports Cache class. */ class Cache { /** * Cache version. Used to invalidate all cached values. */ const VERSION_OPTION = 'woocommerce_reports'; /** * Invalidate cache. */ public static function invalidate() { \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION, true ); } /** * Get cache version number. * * @return string */ public static function get_version() { $version = \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION ); return $version; } /** * Get cached value. * * @param string $key Cache key. * @return mixed */ public static function get( $key ) { $transient_version = self::get_version(); $transient_value = get_transient( $key ); if ( isset( $transient_value['value'], $transient_value['version'] ) && $transient_value['version'] === $transient_version ) { return $transient_value['value']; } return false; } /** * Update cached value. * * @param string $key Cache key. * @param mixed $value New value. * @return bool */ public static function set( $key, $value ) { $transient_version = self::get_version(); $transient_value = array( 'version' => $transient_version, 'value' => $value, ); $result = set_transient( $key, $transient_value, WEEK_IN_SECONDS ); return $result; } } Plugins.php 0000777 00000052341 15252174340 0006715 0 ustar 00 <?php /** * REST API Plugins Controller * * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Plugins Controller. * * @internal * @extends \WC_REST_Data_Controller */ class Plugins extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'plugins'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'install_plugins' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_installation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install/status/(?P<job_id>[a-z0-9_\-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_job_installation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/active', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'active_plugins' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/installed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'installed_plugins' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_plugins' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_activation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate/status/(?P<job_id>[a-z0-9_\-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_job_activation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-jetpack', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'connect_jetpack' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/request-wccom-connect', array( array( 'methods' => 'POST', 'callback' => array( $this, 'request_wccom_connect' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/finish-wccom-connect', array( array( 'methods' => 'POST', 'callback' => array( $this, 'finish_wccom_connect' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-wcpay', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'connect_wcpay' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-square', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'connect_square' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Install the requested plugin. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function install_plugin( $request ) { wc_deprecated_function( 'install_plugin', '4.3', '\Automattic\WooCommerce\Admin\API\Plugins()->install_plugins' ); // This method expects a `plugin` argument to be sent, install plugins requires plugins. $request['plugins'] = $request['plugin']; return self::install_plugins( $request ); } /** * Installs the requested plugins. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function install_plugins( $request ) { $plugins = explode( ',', $request['plugins'] ); $source = ! empty( $request['source'] ) ? $request['source'] : null; if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } if ( isset( $request['async'] ) && $request['async'] ) { $job_id = PluginsHelper::schedule_install_plugins( $plugins ); return array( 'data' => array( 'job_id' => $job_id, 'plugins' => $plugins, ), 'message' => __( 'Plugin installation has been scheduled.', 'woocommerce' ), ); } $data = PluginsHelper::install_plugins( $plugins, null, $source ); // Gather some plugin details for each installed plugin. $plugin_details = array(); if ( is_array( $data['installed'] ) ) { foreach ( $data['installed'] as $plugin_slug ) { $plugin_data = PluginsHelper::get_plugin_data( $plugin_slug ); if ( empty( $plugin_data ) ) { continue; } $plugin_details[ $plugin_slug ] = array( 'name' => $plugin_data['Name'], 'description' => $plugin_data['Description'], 'uri' => $plugin_data['PluginURI'], 'version' => $plugin_data['Version'], ); } } return array( 'data' => array( 'installed' => $data['installed'], 'results' => $data['results'], 'install_time' => $data['time'], 'plugin_details' => $plugin_details, ), 'errors' => $data['errors'], 'success' => count( $data['errors']->errors ) === 0, 'message' => count( $data['errors']->errors ) === 0 ? __( 'Plugins were successfully installed.', 'woocommerce' ) : __( 'There was a problem installing some of the requested plugins.', 'woocommerce' ), ); } /** * Returns a list of recently scheduled installation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Jobs. */ public function get_installation_status( $request ) { return PluginsHelper::get_installation_status(); } /** * Returns a list of recently scheduled installation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Job. */ public function get_job_installation_status( $request ) { $job_id = $request->get_param( 'job_id' ); $jobs = PluginsHelper::get_installation_status( $job_id ); return reset( $jobs ); } /** * Returns a list of active plugins in API format. * * @return array Active plugins */ public static function active_plugins() { return( array( 'plugins' => array_values( PluginsHelper::get_active_plugin_slugs() ), ) ); } /** * Returns a list of active plugins. * * @internal * @return array Active plugins */ public static function get_active_plugins() { $data = self::active_plugins(); return $data['plugins']; } /** * Returns a list of installed plugins. * * @return array Installed plugins */ public function installed_plugins() { return( array( 'plugins' => PluginsHelper::get_installed_plugin_slugs(), ) ); } /** * Activate the requested plugin. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function activate_plugins( $request ) { $plugins = explode( ',', $request['plugins'] ); if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } if ( isset( $request['async'] ) && $request['async'] ) { $job_id = PluginsHelper::schedule_activate_plugins( $plugins ); return array( 'data' => array( 'job_id' => $job_id, 'plugins' => $plugins, ), 'message' => __( 'Plugin activation has been scheduled.', 'woocommerce' ), ); } $data = PluginsHelper::activate_plugins( $plugins ); // Gather some plugin details for each activated plugin. $plugin_details = array(); if ( is_array( $data['activated'] ) ) { foreach ( $data['activated'] as $plugin_slug ) { $plugin_data = PluginsHelper::get_plugin_data( $plugin_slug ); if ( empty( $plugin_data ) ) { continue; } $plugin_details[ $plugin_slug ] = array( 'name' => $plugin_data['Name'], 'description' => $plugin_data['Description'], 'uri' => $plugin_data['PluginURI'], 'version' => $plugin_data['Version'], ); } } return ( array( 'data' => array( 'activated' => $data['activated'], 'active' => $data['active'], 'plugin_details' => $plugin_details, ), 'errors' => $data['errors'], 'success' => count( $data['errors']->errors ) === 0, 'message' => count( $data['errors']->errors ) === 0 ? __( 'Plugins were successfully activated.', 'woocommerce' ) : __( 'There was a problem activating some of the requested plugins.', 'woocommerce' ), ) ); } /** * Returns a list of recently scheduled activation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Job. */ public function get_activation_status( $request ) { return PluginsHelper::get_activation_status(); } /** * Returns a list of recently scheduled activation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Jobs. */ public function get_job_activation_status( $request ) { $job_id = $request->get_param( 'job_id' ); $jobs = PluginsHelper::get_activation_status( $job_id ); return reset( $jobs ); } /** * Generates a Jetpack Connect URL. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Connection URL for Jetpack */ public function connect_jetpack( $request ) { if ( ! class_exists( '\Jetpack' ) ) { return new \WP_Error( 'woocommerce_rest_jetpack_not_active', __( 'Jetpack is not installed or active.', 'woocommerce' ), 404 ); } // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment $redirect_url = apply_filters( 'woocommerce_admin_onboarding_jetpack_connect_redirect_url', esc_url_raw( $request['redirect_url'] ) ); $connect_url = \Jetpack::init()->build_connect_url( true, $redirect_url, 'woocommerce-onboarding' ); $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; $connect_url = add_query_arg( array( 'calypso_env' => $calypso_env ), $connect_url ); return( array( 'slug' => 'jetpack', 'name' => __( 'Jetpack', 'woocommerce' ), 'connectAction' => $connect_url, ) ); } /** * Kicks off the WCCOM Connect process. * * @return \WP_Error|array Connection URL for WooCommerce.com */ public function request_wccom_connect() { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php'; if ( ! class_exists( 'WC_Helper_API' ) ) { return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 ); } $redirect_uri = wc_admin_url( '&task=connect&wccom-connected=1' ); $request = \WC_Helper_API::post( 'oauth/request_token', array( 'body' => array( 'home_url' => home_url(), 'redirect_uri' => $redirect_uri, ), ) ); $code = wp_remote_retrieve_response_code( $request ); if ( 200 !== $code ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } $secret = json_decode( wp_remote_retrieve_body( $request ) ); if ( empty( $secret ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } do_action( 'woocommerce_helper_connect_start' ); $connect_url = add_query_arg( array( 'home_url' => rawurlencode( home_url() ), 'redirect_uri' => rawurlencode( $redirect_uri ), 'secret' => rawurlencode( $secret ), 'wccom-from' => 'onboarding', ), \WC_Helper_API::url( 'oauth/authorize' ) ); if ( defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ) { $connect_url = add_query_arg( array( 'calypso_env' => WOOCOMMERCE_CALYPSO_ENVIRONMENT, ), $connect_url ); } return( array( 'connectAction' => $connect_url, ) ); } /** * Finishes connecting to WooCommerce.com. * * @param object $rest_request Request details. * @return \WP_Error|array Contains success status. */ public function finish_wccom_connect( $rest_request ) { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-updater.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php'; if ( ! class_exists( 'WC_Helper_API' ) ) { return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 ); } // Obtain an access token. $request = \WC_Helper_API::post( 'oauth/access_token', array( 'body' => array( 'request_token' => wp_unslash( $rest_request['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'home_url' => home_url(), ), ) ); $code = wp_remote_retrieve_response_code( $request ); if ( 200 !== $code ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } $access_token = json_decode( wp_remote_retrieve_body( $request ), true ); if ( ! $access_token ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } \WC_Helper_Options::update( 'auth', array( 'access_token' => $access_token['access_token'], 'access_token_secret' => $access_token['access_token_secret'], 'site_id' => $access_token['site_id'], 'user_id' => get_current_user_id(), 'updated' => time(), ) ); if ( ! \WC_Helper::_flush_authentication_cache() ) { \WC_Helper_Options::update( 'auth', array() ); return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } delete_transient( '_woocommerce_helper_subscriptions' ); \WC_Helper_Updater::flush_updates_cache(); do_action( 'woocommerce_helper_connected' ); return array( 'success' => true, ); } /** * Returns a URL that can be used to connect to Square. * * @return \WP_Error|array Connect URL. */ public function connect_square() { if ( ! class_exists( '\WooCommerce\Square\Handlers\Connection' ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to Square.', 'woocommerce' ), 500 ); } $has_cbd_industry = false; if ( 'US' === WC()->countries->get_base_country() ) { $profile = get_option( OnboardingProfile::DATA_OPTION, array() ); if ( ! empty( $profile['industry'] ) ) { $has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true ); } } if ( $has_cbd_industry ) { $url = 'https://squareup.com/t/f_partnerships/d_referrals/p_woocommerce/c_general/o_none/l_us/dt_alldevice/pr_payments/?route=/solutions/cbd'; } else { $url = \WooCommerce\Square\Handlers\Connection::CONNECT_URL_PRODUCTION; } $redirect_url = wp_nonce_url( wc_admin_url( '&task=payments&method=square&square-connect-finish=1' ), 'wc_square_connected' ); $args = array( 'redirect' => rawurlencode( rawurlencode( $redirect_url ) ), 'scopes' => implode( ',', array( 'MERCHANT_PROFILE_READ', 'PAYMENTS_READ', 'PAYMENTS_WRITE', 'ORDERS_READ', 'ORDERS_WRITE', 'CUSTOMERS_READ', 'CUSTOMERS_WRITE', 'SETTLEMENTS_READ', 'ITEMS_READ', 'ITEMS_WRITE', 'INVENTORY_READ', 'INVENTORY_WRITE', ) ), ); $connect_url = add_query_arg( $args, $url ); return( array( 'connectUrl' => $connect_url, ) ); } /** * Returns a URL that can be used to point the merchant to the WooPayments onboarding flow. * * @return \WP_Error|array Connect URL. */ public function connect_wcpay() { if ( ! class_exists( 'WC_Payments' ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error communicating with the WooPayments plugin.', 'woocommerce' ), 500 ); } // Use a WooPayments connect link to let the WooPayments plugin handle the connection flow. return array( 'connectUrl' => add_query_arg( array( 'wcpay-connect' => '1', 'from' => 'WCADMIN_PAYMENT_TASK', '_wpnonce' => wp_create_nonce( 'wcpay-connect' ), ), admin_url( 'admin.php' ) ), ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'plugins', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'Plugin slug.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Plugin name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Plugin status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_connect_schema() { $schema = $this->get_item_schema(); unset( $schema['properties']['status'] ); $schema['properties']['connectAction'] = array( 'description' => __( 'Action that should be completed to connect Jetpack.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $schema; } } MarketingOverview.php 0000777 00000006563 15252174340 0010751 0 ustar 00 <?php /** * REST API Marketing Overview Controller * * Handles requests to /marketing/overview. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Marketing Overview Controller. * * @internal * @extends WC_REST_Data_Controller */ class MarketingOverview extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/overview'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate-plugin', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_plugin' ), 'permission_callback' => array( $this, 'install_plugins_permissions_check' ), 'args' => array( 'plugin' => array( 'required' => true, 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/installed-plugins', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_installed_plugins' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function activate_plugin( $request ) { $plugin_slug = $request->get_param( 'plugin' ); if ( ! PluginsHelper::is_plugin_installed( $plugin_slug ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'Invalid plugin.', 'woocommerce' ), 404 ); } $result = activate_plugin( PluginsHelper::get_plugin_path_from_slug( $plugin_slug ) ); if ( ! is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'The plugin could not be activated.', 'woocommerce' ), 500 ); } // IMPORTANT - Don't return the active plugins data here. // Instead we will get that data in a separate request to ensure they are loaded. return rest_ensure_response( array( 'status' => 'success', ) ); } /** * Check if a given request has access to manage plugins. * * @param \WP_REST_Request $request Full details about the request. * * @return \WP_Error|boolean */ public function install_plugins_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_installed_plugins( $request ) { return rest_ensure_response( InstalledExtensions::get_data() ); } } Products.php 0000777 00000023353 15252174340 0007100 0 ustar 00 <?php /** * REST API Products Controller * * Handles requests to /products/* */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Products controller. * * @internal * @extends WC_REST_Products_Controller */ class Products extends \WC_REST_Products_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Local cache of last order dates by ID. * * @var array */ protected $last_order_dates = array(); /** * Adds properties that can be embed via ?_embed=1. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $properties_to_embed = array( 'id', 'name', 'slug', 'permalink', 'images', 'description', 'short_description', ); foreach ( $properties_to_embed as $property ) { $schema['properties'][ $property ]['context'][] = 'embed'; } $schema['properties']['last_order_date'] = array( 'description' => __( "The date the last order for this product was placed, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $schema; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['low_in_stock'] = array( 'description' => __( 'Limit result set to products that are low or out of stock. (Deprecated)', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', ); $params['search'] = array( 'description' => __( 'Search by similar product name or sku.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add product name and sku filtering to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = trim( $request['search'] ); unset( $args['s'] ); } if ( ! empty( $request['low_in_stock'] ) ) { $args['low_in_stock'] = $request['low_in_stock']; $args['post_type'] = array( 'product', 'product_variation' ); } return $args; } /** * Get a collection of posts and add the post title filter option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10, 2 ); add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10 ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 ); /** * The low stock query caused performance issues in WooCommerce 5.5.1 * due to a) being slow, and b) multiple requests being made to this endpoint * from WC Admin. * * This is a temporary measure to trigger the user’s browser to cache the * endpoint response for 1 minute, limiting the amount of requests overall. * * https://github.com/woocommerce/woocommerce-admin/issues/7358 */ if ( $this->is_low_in_stock_request( $request ) ) { $response->header( 'Cache-Control', 'max-age=300' ); } return $response; } /** * Check whether the request is for products low in stock. * * It matches requests with parameters: * * low_in_stock = true * page = 1 * fields[0] = id * * @param string $request WP REST API request. * @return boolean Whether the request matches. */ private function is_low_in_stock_request( $request ) { if ( $request->get_param( 'low_in_stock' ) === true && $request->get_param( 'page' ) === 1 && is_array( $request->get_param( '_fields' ) ) && count( $request->get_param( '_fields' ) ) === 1 && in_array( 'id', $request->get_param( '_fields' ), true ) ) { return true; } return false; } /** * Hang onto last order date since it will get removed by wc_get_product(). * * @param stdClass $object_data Single row from query results. * @return WC_Data */ public function get_object( $object_data ) { if ( isset( $object_data->last_order_date ) ) { $this->last_order_dates[ $object_data->ID ] = $object_data->last_order_date; } return parent::get_object( $object_data ); } /** * Add `low_stock_amount` property to product data * * @param WC_Data $object Object data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_object_for_response( $object, $request ) { $data = parent::prepare_object_for_response( $object, $request ); $object_data = $object->get_data(); $product_id = $object_data['id']; if ( $request->get_param( 'low_in_stock' ) ) { if ( is_numeric( $object_data['low_stock_amount'] ) ) { $data->data['low_stock_amount'] = $object_data['low_stock_amount']; } if ( isset( $this->last_order_dates[ $product_id ] ) ) { $data->data['last_order_date'] = wc_rest_prepare_date_response( $this->last_order_dates[ $product_id ] ); } } if ( isset( $data->data['name'] ) ) { $data->data['name'] = wp_strip_all_tags( $data->data['name'] ); } return $data; } /** * Add in conditional select fields to the query. * * @internal * @param string $select Select clause used to select fields from the query. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_fields( $select, $wp_query ) { if ( $wp_query->get( 'low_in_stock' ) ) { $fields = array( 'low_stock_amount_meta.meta_value AS low_stock_amount', 'MAX( product_lookup.date_created ) AS last_order_date', ); $select .= ', ' . implode( ', ', $fields ); } return $select; } /** * Add in conditional search filters for products. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $title_like = '%' . $wpdb->esc_like( $search ) . '%'; $where .= $wpdb->prepare( " AND ({$wpdb->posts}.post_title LIKE %s", $title_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $where .= wc_product_sku_enabled() ? $wpdb->prepare( ' OR wc_product_meta_lookup.sku LIKE %s)', $search ) : ')'; } if ( $wp_query->get( 'low_in_stock' ) ) { $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $where .= " AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status IN('instock','outofstock') AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount} ) )"; } return $where; } /** * Join posts meta tables when product search or low stock query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search && wc_product_sku_enabled() ) { $join = self::append_product_sorting_table_join( $join ); } if ( $wp_query->get( 'low_in_stock' ) ) { $product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $join = self::append_product_sorting_table_join( $join ); $join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' "; $join .= " LEFT JOIN {$product_lookup_table} product_lookup ON {$wpdb->posts}.ID = CASE WHEN {$wpdb->posts}.post_type = 'product' THEN product_lookup.product_id WHEN {$wpdb->posts}.post_type = 'product_variation' THEN product_lookup.variation_id END"; } return $join; } /** * Join wc_product_meta_lookup to posts if not already joined. * * @internal * @param string $sql SQL join. * @return string */ protected static function append_product_sorting_table_join( $sql ) { global $wpdb; if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) { $sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; } return $sql; } /** * Group by post ID to prevent duplicates. * * @internal * @param string $groupby Group by clause used to organize posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_group_by( $groupby, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); $low_in_stock = $wp_query->get( 'low_in_stock' ); if ( empty( $groupby ) && ( $search || $low_in_stock ) ) { $groupby = $wpdb->posts . '.ID'; } return $groupby; } } ProductAttributeTerms.php 0000777 00000010563 15252174340 0011613 0 ustar 00 <?php /** * REST API Product Attribute Terms Controller * * Handles requests to /products/attributes/<slug>/terms */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product attribute terms controller. * * @internal * @extends WC_REST_Product_Attribute_Terms_Controller */ class ProductAttributeTerms extends \WC_REST_Product_Attribute_Terms_Controller { use CustomAttributeTraits; /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for custom product attributes. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, 'products/attributes/(?P<slug>[a-z0-9_\-]+)/terms', array( 'args' => array( 'slug' => array( 'description' => __( 'Slug identifier for the resource.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item_by_slug' ), 'permission_callback' => array( $this, 'get_custom_attribute_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to read a custom attribute. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_custom_attribute_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'attributes', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code(), ) ); } return true; } /** * Get the Attribute's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); // Custom attributes substitute slugs for numeric IDs. $schema['properties']['id']['type'] = array( 'integer', 'string' ); return $schema; } /** * Query custom attribute values by slug. * * @param string $slug Attribute slug. * @return array Attribute values, formatted for response. */ protected function get_custom_attribute_values( $slug ) { global $wpdb; if ( empty( $slug ) ) { return array(); } $attribute_values = array(); // Get the attribute properties. $attribute = $this->get_custom_attribute_by_slug( $slug ); if ( is_wp_error( $attribute ) ) { return $attribute; } // Find all attribute values assigned to products. $query_results = $wpdb->get_results( $wpdb->prepare( "SELECT meta_value, COUNT(meta_id) AS product_count FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != '' GROUP BY meta_value", 'attribute_' . esc_sql( $slug ) ), OBJECT_K ); // Ensure all defined properties are in the response. $defined_values = wc_get_text_attributes( $attribute[ $slug ]['value'] ); foreach ( $defined_values as $defined_value ) { if ( array_key_exists( $defined_value, $query_results ) ) { continue; } $query_results[ $defined_value ] = (object) array( 'meta_value' => $defined_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'product_count' => 0, ); } foreach ( $query_results as $term_value => $term ) { // Mimic the structure of a taxonomy-backed attribute values for response. $data = array( 'id' => $term_value, 'name' => $term_value, 'slug' => $term_value, 'description' => '', 'menu_order' => 0, 'count' => (int) $term->product_count, ); $response = rest_ensure_response( $data ); $response->add_links( array( 'collection' => array( 'href' => rest_url( $this->namespace . '/products/attributes/' . $slug . '/terms' ), ), ) ); $response = $this->prepare_response_for_collection( $response ); $attribute_values[ $term_value ] = $response; } return array_values( $attribute_values ); } /** * Get a single custom attribute. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function get_item_by_slug( $request ) { return $this->get_custom_attribute_values( $request['slug'] ); } } OnboardingProducts.php 0000777 00000003677 15252174340 0011112 0 ustar 00 <?php /** * REST API Onboarding Themes Controller * * Handles requests to install and activate themes. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Blocks\AIContent\UpdateProducts; defined( 'ABSPATH' ) || exit; /** * Onboarding Themes Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProducts extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/products', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_products' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Create products. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function create_products( $request ) { $update_products = new UpdateProducts(); $products = $update_products->fetch_dummy_products_to_update(); if ( is_wp_error( $products ) ) { return rest_ensure_response( array( 'success' => false ) ); } return rest_ensure_response( array( 'success' => true ) ); } /** * Check if a given request has access to manage themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot create dummy products.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } } MarketingCampaignTypes.php 0000777 00000014020 15252174340 0011672 0 ustar 00 <?php /** * REST API MarketingCampaignTypes Controller * * Handles requests to /marketing/campaign-types. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingCampaignType; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingCampaignTypes Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingCampaignTypes extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/campaign-types'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Retrieves the query params for the collections. * * @return array Query parameters for the collection. */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['search'] ); return $params; } /** * Check whether a given request has permission to view marketing campaigns. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Returns an aggregated array of marketing campaigns for all active marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); // Aggregate the supported campaign types from all registered marketing channels. $responses = []; foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { foreach ( $channel->get_supported_campaign_types() as $campaign_type ) { $response = $this->prepare_item_for_response( $campaign_type, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param MarketingCampaignType $item WordPress representation of the item. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function prepare_item_for_response( $item, $request ) { $data = [ 'id' => $item->get_id(), 'name' => $item->get_name(), 'description' => $item->get_description(), 'channel' => [ 'slug' => $item->get_channel()->get_slug(), 'name' => $item->get_channel()->get_name(), ], 'create_url' => $item->get_create_url(), 'icon_url' => $item->get_icon_url(), ]; $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_campaign_type', 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The unique identifier for the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Description of the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'channel' => [ 'description' => __( 'The marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'description' => __( 'The unique identifier of the marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'The name of the marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], 'create_url' => [ 'description' => __( 'URL to the create campaign page for this campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon_url' => [ 'description' => __( 'URL to an image/icon for the campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ]; return $this->add_additional_fields_schema( $schema ); } } ShippingPartnerSuggestions.php 0000777 00000013364 15252174340 0012646 0 ustar 00 <?php /** * Handles requests for shipping partner suggestions. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\DefaultShippingPartners; use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\ShippingPartnerSuggestions as Suggestions; defined( 'ABSPATH' ) || exit; /** * ShippingPartnerSuggestions Controller. * * @internal * @extends WC_REST_Data_Controller */ class ShippingPartnerSuggestions extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'shipping-partner-suggestions'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_suggestions' ), 'permission_callback' => array( $this, 'get_permission_check' ), 'args' => array( 'force_default_suggestions' => array( 'type' => 'boolean', 'description' => __( 'Return the default shipping partner suggestions when woocommerce_show_marketplace_suggestions option is set to no', 'woocommerce' ), ), ), ), 'schema' => array( $this, 'get_suggestions_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_permission_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if suggestions should be shown in the settings screen. * * @return bool */ private function should_display() { if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) { return false; } /** * The return value can be controlled via woocommerce_allow_shipping_partner_suggestions filter. * * @since 7.4.1 */ return apply_filters( 'woocommerce_allow_shipping_partner_suggestions', true ); } /** * Return suggested shipping partners. * * @param WP_REST_Request $request Full details about the request. * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_suggestions( $request ) { $should_display = $this->should_display(); $force_default = $request->get_param( 'force_default_suggestions' ); if ( $should_display ) { return Suggestions::get_suggestions(); } elseif ( false === $should_display && true === $force_default ) { return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) ); } return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public static function get_suggestions_schema() { $feature_def = array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'icon' => array( 'type' => 'string', ), 'title' => array( 'type' => 'string', ), 'description' => array( 'type' => 'string', ), ), ), ); $layout_def = array( 'type' => 'object', 'properties' => array( 'image' => array( 'type' => 'string', 'description' => '', ), 'features' => $feature_def, ), ); $item_schema = array( 'type' => 'object', 'required' => array( 'name', 'is_visible', 'available_layouts' ), // require layout_row or layout_column. One of them must exist. 'anyOf' => array( array( 'required' => 'layout_row', ), array( 'required' => 'layout_column', ), ), 'properties' => array( 'name' => array( 'description' => __( 'Plugin name.', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'slug' => array( 'description' => __( 'Plugin slug used in https://wordpress.org/plugins/{slug}.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'layout_row' => $layout_def, 'layout_column' => $layout_def, 'description' => array( 'description' => __( 'Description', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'learn_more_link' => array( 'description' => __( 'Learn more link .', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_visible' => array( 'description' => __( 'Suggestion visibility.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'available_layouts' => array( 'description' => __( 'Available layouts -- single, dual, or both', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => array( 'row', 'column' ), ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'shipping-partner-suggestions', 'type' => 'array', 'items' => array( $item_schema ), ); return $schema; } } Themes.php 0000777 00000014167 15252174340 0006525 0 ustar 00 <?php /** * REST API Themes Controller * * Handles requests to /themes */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Overrides\ThemeUpgrader; use Automattic\WooCommerce\Admin\Overrides\ThemeUpgraderSkin; /** * Themes controller. * * @internal * @extends WC_REST_Data_Controller */ class Themes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'themes'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'upload_theme' ), 'permission_callback' => array( $this, 'upload_theme_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to edit upload plugins/themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function upload_theme_permissions_check( $request ) { if ( ! current_user_can( 'upload_themes' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you are not allowed to install themes on this site.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Upload and install a theme. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function upload_theme( $request ) { if ( ! isset( $_FILES['pluginzip'] ) || ! isset( $_FILES['pluginzip']['tmp_name'] ) || ! is_uploaded_file( $_FILES['pluginzip']['tmp_name'] ) || ! is_file( $_FILES['pluginzip']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return new \WP_Error( 'woocommerce_rest_invalid_file', __( 'Specified file failed upload test.', 'woocommerce' ) ); } include_once ABSPATH . 'wp-admin/includes/file.php'; include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/theme-install.php'; include_once ABSPATH . '/wp-admin/includes/theme.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php'; $_GET['package'] = true; $file_upload = new \File_Upload_Upgrader( 'pluginzip', 'package' ); $upgrader = new ThemeUpgrader( new ThemeUpgraderSkin() ); $install = $upgrader->install( $file_upload->package ); if ( $install || is_wp_error( $install ) ) { $file_upload->cleanup(); } if ( ! is_wp_error( $install ) && isset( $install['destination_name'] ) ) { $theme = $install['destination_name']; $result = array( 'status' => 'success', 'message' => $upgrader->strings['process_success'], 'theme' => $theme, ); /** * Fires when a theme is successfully installed. * * @param string $theme The theme name. */ do_action( 'woocommerce_theme_installed', $theme ); } else { if ( is_wp_error( $install ) && $install->get_error_code() ) { $error_message = isset( $upgrader->strings[ $install->get_error_code() ] ) ? $upgrader->strings[ $install->get_error_code() ] : $install->get_error_data(); } else { $error_message = $upgrader->strings['process_failed']; } $result = array( 'status' => 'error', 'message' => $error_message, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_themes', $response, $item, $request ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'upload_theme', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Theme installation status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Theme installation message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'theme' => array( 'description' => __( 'Uploaded theme.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ) ); $params['pluginzip'] = array( 'description' => __( 'A zip file of the theme to be uploaded.', 'woocommerce' ), 'type' => 'file', 'validate_callback' => 'rest_validate_request_arg', ); return apply_filters( 'woocommerce_rest_themes_collection_params', $params ); } } Options.php 0000777 00000023774 15252174340 0006737 0 ustar 00 <?php /** * REST API Options Controller * * Handles requests to get and update options in the wp_options table. * * IMPORTANT: This API is for legacy support only. DO NOT add new options here. See p90Yrv-2vK-p2#comment-6482 for more details. * For new settings/options, use Settings REST API (https://woocommerce.github.io/woocommerce-rest-api-docs/#setting-option-properties) or create dedicated endpoints instead. * * Example: * - Use register_rest_route() to create a new endpoint * - Follow WooCommerce REST API standards * - Implement proper permission checks * - Add proper documentation * See Automattic\WooCommerce\Admin\API\OnboardingProfile for examples. */ declare(strict_types=1); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Options Controller. * * @deprecated since 6.2.0 * * @extends WC_REST_Data_Controller */ class Options extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'options'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_options' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_options' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to get options. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_item_permissions_check( $request ) { $params = ( isset( $request['options'] ) && is_string( $request['options'] ) ) ? explode( ',', $request['options'] ) : array(); if ( ! $params ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'You must supply an array of options.', 'woocommerce' ), 500 ); } foreach ( $params as $option ) { if ( ! $this->user_has_permission( $option, $request ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } } return true; } /** * Check if the user has permission given an option name. * * @param string $option Option name. * @param WP_REST_Request $request Full details about the request. * @param bool $is_update If the request is to update the option. * @return boolean */ public function user_has_permission( $option, $request, $is_update = false ) { $permissions = $this->get_option_permissions( $request ); if ( isset( $permissions[ $option ] ) ) { return $permissions[ $option ]; } wc_deprecated_function( 'Automattic\WooCommerce\Admin\API\Options::' . ( $is_update ? 'update_options' : 'get_options' ), '6.3' ); // Disallow option updates in non-production environments unless the option is whitelisted, prompting developers to create specific endpoints in case they miss the deprecation notice. if ( 'production' !== wp_get_environment_type() ) { return false; } return current_user_can( 'manage_options' ); } /** * Check if a given request has access to update options. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'You must supply an array of options and values.', 'woocommerce' ), 500 ); } foreach ( $params as $option_name => $option_value ) { if ( ! $this->user_has_permission( $option_name, $request, true ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } } return true; } /** * Get an array of options and respective permissions for the current user. * * @param WP_REST_Request $request Full details about the request. * @return array */ public function get_option_permissions( $request ) { $permissions = self::get_default_option_permissions(); return apply_filters_deprecated( 'woocommerce_rest_api_option_permissions', array( $permissions, $request ), '6.3.0' ); } /** * Get the default available option permissions. * * @return array */ public static function get_default_option_permissions() { $is_woocommerce_admin = \Automattic\WooCommerce\Internal\Admin\Homescreen::is_admin_user(); /** * IMPORTANT: This list is frozen for legacy support. * New options MUST use dedicated endpoints instead of being added here. */ $legacy_whitelisted_options = array( 'woocommerce_setup_jetpack_opted_in', 'woocommerce_stripe_settings', 'woocommerce-ppcp-settings', 'woocommerce_ppcp-gateway_setting', 'woocommerce_demo_store', 'woocommerce_demo_store_notice', 'woocommerce_ces_tracks_queue', 'woocommerce_navigation_intro_modal_dismissed', 'woocommerce_shipping_dismissed_timestamp', 'woocommerce_allow_tracking', 'woocommerce_task_list_keep_completed', 'woocommerce_default_homepage_layout', 'woocommerce_setup_jetpack_opted_in', 'woocommerce_no_sales_tax', 'woocommerce_calc_taxes', 'woocommerce_bacs_settings', 'woocommerce_bacs_accounts', 'woocommerce_settings_shipping_recommendations_hidden', 'woocommerce_task_list_dismissed_tasks', 'woocommerce_setting_payments_recommendations_hidden', 'woocommerce_navigation_favorites_tooltip_hidden', 'woocommerce_admin_transient_notices_queue', 'woocommerce_task_list_hidden', 'woocommerce_task_list_complete', 'woocommerce_extended_task_list_hidden', 'woocommerce_ces_shown_for_actions', 'woocommerce_clear_ces_tracks_queue_for_page', 'woocommerce_admin_install_timestamp', 'woocommerce_task_list_tracked_completed_tasks', 'woocommerce_show_marketplace_suggestions', 'woocommerce_task_list_reminder_bar_hidden', 'wc_connect_options', 'woocommerce_admin_created_default_shipping_zones', 'woocommerce_admin_reviewed_default_shipping_zones', 'woocommerce_admin_reviewed_store_location_settings', 'woocommerce_ces_product_feedback_shown', 'woocommerce_marketing_overview_multichannel_banner_dismissed', 'woocommerce_manage_stock', 'woocommerce_dimension_unit', 'woocommerce_weight_unit', 'woocommerce_product_editor_show_feedback_bar', 'woocommerce_single_variation_notice_dismissed', 'woocommerce_product_tour_modal_hidden', 'woocommerce_block_product_tour_shown', 'woocommerce_revenue_report_date_tour_shown', 'woocommerce_orders_report_date_tour_shown', 'woocommerce_show_prepublish_checks_enabled', 'woocommerce_date_type', 'date_format', 'time_format', 'woocommerce_onboarding_profile', 'woocommerce_default_country', 'blogname', 'wcpay_welcome_page_incentives_dismissed', 'wcpay_welcome_page_viewed_timestamp', 'wcpay_welcome_page_exit_survey_more_info_needed_timestamp', 'woocommerce_customize_store_onboarding_tour_hidden', 'woocommerce_customize_store_ai_suggestions', 'woocommerce_admin_customize_store_completed', 'woocommerce_admin_customize_store_completed_theme_id', 'woocommerce_admin_customize_store_survey_completed', 'woocommerce_coming_soon', 'woocommerce_store_pages_only', 'woocommerce_private_link', 'woocommerce_share_key', 'woocommerce_show_lys_tour', 'woocommerce_remote_variant_assignment', 'woocommerce_gateway_order', 'woocommerce_woopayments_nox_profile', // WC Test helper options. 'wc-admin-test-helper-rest-api-filters', 'wc_admin_helper_feature_values', ); $theme_permissions = array( 'theme_mods_' . get_stylesheet() => current_user_can( 'edit_theme_options' ), 'stylesheet' => current_user_can( 'edit_theme_options' ), ); return array_merge( array_fill_keys( $theme_permissions, current_user_can( 'edit_theme_options' ) ), array_fill_keys( $legacy_whitelisted_options, $is_woocommerce_admin ) ); } /** * Gets an array of options and respective values. * * @param WP_REST_Request $request Full details about the request. * @return array Options object with option values. */ public function get_options( $request ) { $options = array(); if ( empty( $request['options'] ) || ! is_string( $request['options'] ) ) { return $options; } $params = explode( ',', $request['options'] ); foreach ( $params as $option ) { $options[ $option ] = get_option( $option ); } return $options; } /** * Updates an array of objects. * * @param WP_REST_Request $request Full details about the request. * @return array Options object with a boolean if the option was updated. */ public function update_options( $request ) { $params = $request->get_json_params(); $updated = array(); if ( ! is_array( $params ) ) { return array(); } foreach ( $params as $key => $value ) { $updated[ $key ] = update_option( $key, $value ); } return $updated; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'options', 'type' => 'object', 'properties' => array( 'options' => array( 'type' => 'array', 'description' => __( 'Array of options with associated values.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } ProductForm.php 0000777 00000006101 15252174340 0007531 0 ustar 00 <?php /** * REST API Product Form Controller * * Handles requests to retrieve product form data. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\ProductForm\FormFactory; defined( 'ABSPATH' ) || exit; /** * ProductForm Controller. * * @internal * @extends WC_REST_Data_Controller */ class ProductForm extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'product-form'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_form_config' ), 'permission_callback' => array( $this, 'get_product_form_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/fields', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_fields' ), 'permission_callback' => array( $this, 'get_product_form_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to manage woocommerce. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_product_form_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve product form data.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Get the form fields. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_fields( $request ) { $json = array_map( function( $field ) { return $field->get_json(); }, FormFactory::get_fields() ); return rest_ensure_response( $json ); } /** * Get the form config. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_form_config( $request ) { $fields = array_map( function( $field ) { return $field->get_json(); }, FormFactory::get_fields() ); $subsections = array_map( function( $subsection ) { return $subsection->get_json(); }, FormFactory::get_subsections() ); $sections = array_map( function( $section ) { return $section->get_json(); }, FormFactory::get_sections() ); $tabs = array_map( function( $tab ) { return $tab->get_json(); }, FormFactory::get_tabs() ); return rest_ensure_response( array( 'fields' => $fields, 'subsections' => $subsections, 'sections' => $sections, 'tabs' => $tabs, ) ); } } Templates/variable_product.csv 0000777 00000000040 15252174340 0012550 0 ustar 00 Type,Name,Published variable,,-1 Templates/physical_product.csv 0000777 00000000036 15252174340 0012604 0 ustar 00 Type,Name,Published simple,,-1 Templates/digital_product.csv 0000777 00000000067 15252174340 0012411 0 ustar 00 Type,Name,Published "simple, downloadable, virtual",,-1 Templates/external_product.csv 0000777 00000000040 15252174340 0012605 0 ustar 00 Type,Name,Published external,,-1 Templates/grouped_product.csv 0000777 00000000040 15252174340 0012430 0 ustar 00 Type,Name,Published grouped,,-1 AnalyticsImports.php 0000777 00000022130 15252174340 0010572 0 ustar 00 <?php /** * REST API Analytics Imports Controller * * Handles requests to get batch import status and trigger manual imports. */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API; use WP_Error; use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler; defined( 'ABSPATH' ) || exit; /** * REST API Analytics Imports Controller. * * @internal */ class AnalyticsImports extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'imports'; /** * Register routes. * * @return void */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_status_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/trigger', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'trigger_import' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_trigger_schema' ), ) ); } /** * Check if a given request has access to analytics imports. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return WP_Error|boolean */ public function permissions_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new WP_Error( 'woocommerce_rest_cannot_access', __( 'Sorry, you cannot access analytics imports.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Get the current import status. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_status( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); $mode = $is_scheduled_mode ? 'scheduled' : 'immediate'; $response = array( 'mode' => $mode, 'last_processed_date' => null, 'next_scheduled' => null, 'import_in_progress_or_due' => null, ); // For scheduled mode, populate additional fields. if ( $is_scheduled_mode ) { $last_processed_gmt = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, null ); $response['last_processed_date'] = ( is_string( $last_processed_gmt ) && $last_processed_gmt ) ? get_date_from_gmt( $last_processed_gmt, 'Y-m-d H:i:s' ) : null; $response['next_scheduled'] = $this->get_next_scheduled_time(); $response['import_in_progress_or_due'] = $this->is_import_in_progress_or_due(); } return rest_ensure_response( $response ); } /** * Trigger a manual import. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function trigger_import( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); // Return error if in immediate mode. if ( ! $is_scheduled_mode ) { return new WP_Error( 'woocommerce_rest_analytics_import_immediate_mode', __( 'Manual import is not available in immediate mode. Imports happen automatically.', 'woocommerce' ), array( 'status' => 400 ) ); } // Check if an import is already in progress or due to run soon. if ( $this->is_import_in_progress_or_due() ) { return new WP_Error( 'woocommerce_rest_analytics_import_in_progress', __( 'A batch import is already in progress or scheduled to run soon. Please wait for it to complete before triggering a new import.', 'woocommerce' ), array( 'status' => 400 ) ); } // Trigger the batch import immediately by rescheduling the recurring processor. // This unschedules the current recurring action and reschedules it to run now. $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return new WP_Error( 'woocommerce_rest_analytics_import_invalid_action', __( 'Invalid action hook for batch import.', 'woocommerce' ), array( 'status' => 500 ) ); } WC()->queue()->cancel_all( $action_hook, array(), (string) OrdersScheduler::$group ); OrdersScheduler::schedule_recurring_batch_processor(); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Batch import triggered successfully.', 'woocommerce' ), ) ); } /** * Check if scheduled import is enabled. * * @return bool */ private function is_scheduled_import_enabled() { return 'yes' === get_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, OrdersScheduler::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ); } /** * Get the next scheduled time for the batch processor. * * @return string|null Datetime string in site timezone or null if not scheduled. */ private function get_next_scheduled_time() { $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return null; } $next_time = WC()->queue()->get_next( $action_hook, array(), (string) OrdersScheduler::$group ); if ( ! $next_time ) { return null; } // Convert UTC timestamp to site timezone. return get_date_from_gmt( $next_time->format( 'Y-m-d H:i:s' ), 'Y-m-d H:i:s' ); } /** * Get the schema for the status endpoint, conforming to JSON Schema. * * @return array */ public function get_status_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_status', 'type' => 'object', 'properties' => array( 'mode' => array( 'type' => 'string', 'enum' => array( 'scheduled', 'immediate' ), 'description' => __( 'Current import mode.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'last_processed_date' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Last processed order date (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'next_scheduled' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Next scheduled import time (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'import_in_progress_or_due' => array( 'type' => array( 'boolean', 'null' ), 'description' => __( 'Whether a batch import is currently running or scheduled to run within the next minute (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the schema for the trigger endpoint, conforming to JSON Schema. * * @return array */ public function get_trigger_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_trigger', 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean', 'description' => __( 'Whether the trigger was successful.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'message' => array( 'type' => 'string', 'description' => __( 'Result message.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Check if a batch import is currently in progress or due to run soon. * * @return bool True if a batch import is in progress or scheduled to run within the next minute, false otherwise. */ private function is_import_in_progress_or_due() { $hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $hook ) ) { return false; } // Check for actions with 'in-progress' status. $in_progress_actions = WC()->queue()->search( array( 'hook' => $hook, 'status' => 'in-progress', 'per_page' => 1, ), 'ids' ); if ( ! empty( $in_progress_actions ) ) { return true; } // Check if the next scheduled import is due within 1 minute. $next_scheduled = WC()->queue()->get_next( $hook, array(), (string) OrdersScheduler::$group ); if ( $next_scheduled ) { $time_until_next = $next_scheduled->getTimestamp() - time(); // Consider it "due" if it's scheduled to run within the next 60 seconds. if ( $time_until_next <= MINUTE_IN_SECONDS ) { return true; } } return false; } } Leaderboards.php 0000777 00000044344 15252174340 0007667 0 ustar 00 <?php /** * REST API Leaderboards Controller * * Handles requests to /leaderboards */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore as CategoriesDataStore; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore; /** * Leaderboards controller. * * @internal * @extends WC_REST_Data_Controller */ class Leaderboards extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'leaderboards'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/allowed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_allowed_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_allowed_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<leaderboard>\w+)', array( 'args' => array( 'leaderboard' => array( 'type' => 'string', 'enum' => array( 'customers', 'coupons', 'categories', 'products' ), ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the data for the coupons leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ) { $coupons_data_store = new CouponsDataStore(); $coupons_data = $per_page > 0 ? $coupons_data_store->get_data( apply_filters( 'woocommerce_analytics_coupons_query_args', array( 'orderby' => 'orders_count', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $coupons_data as $coupon ) { $url_query = wp_parse_args( array( 'filter' => 'single_coupon', 'coupons' => $coupon['coupon_id'], ), $persisted_query ); $coupon_url = wc_admin_url( '/analytics/coupons', $url_query ); $coupon_code = isset( $coupon['extended_info'] ) && isset( $coupon['extended_info']['code'] ) ? $coupon['extended_info']['code'] : ''; $rows[] = array( array( 'display' => "<a href='{$coupon_url}'>{$coupon_code}</a>", 'value' => $coupon_code, ), array( 'display' => wc_admin_number_format( $coupon['orders_count'] ), 'value' => $coupon['orders_count'], 'format' => 'number', ), array( 'display' => wc_price( $coupon['amount'] ), 'value' => $coupon['amount'], 'format' => 'currency', ), ); } return array( 'id' => 'coupons', 'label' => __( 'Top Coupons - Number of Orders', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Coupon code', 'woocommerce' ), ), array( 'label' => __( 'Orders', 'woocommerce' ), ), array( 'label' => __( 'Amount discounted', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the categories leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_categories_leaderboard( $per_page, $after, $before, $persisted_query ) { $categories_data_store = new CategoriesDataStore(); $categories_data = $per_page > 0 ? $categories_data_store->get_data( apply_filters( 'woocommerce_analytics_categories_query_args', array( 'orderby' => 'items_sold', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $categories_data as $category ) { $url_query = wp_parse_args( array( 'filter' => 'single_category', 'categories' => $category['category_id'], ), $persisted_query ); $category_url = wc_admin_url( '/analytics/categories', $url_query ); $category_name = isset( $category['extended_info'] ) && isset( $category['extended_info']['name'] ) ? $category['extended_info']['name'] : ''; $rows[] = array( array( 'display' => "<a href='{$category_url}'>{$category_name}</a>", 'value' => $category_name, ), array( 'display' => wc_admin_number_format( $category['items_sold'] ), 'value' => $category['items_sold'], 'format' => 'number', ), array( 'display' => wc_price( $category['net_revenue'] ), 'value' => $category['net_revenue'], 'format' => 'currency', ), ); } return array( 'id' => 'categories', 'label' => __( 'Top categories - Items sold', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Category', 'woocommerce' ), ), array( 'label' => __( 'Items sold', 'woocommerce' ), ), array( 'label' => __( 'Net sales', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the customers leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_customers_leaderboard( $per_page, $after, $before, $persisted_query ) { $customers_data_store = new CustomersDataStore(); $customers_data = $per_page > 0 ? $customers_data_store->get_data( apply_filters( 'woocommerce_analytics_customers_query_args', array( 'orderby' => 'total_spend', 'order' => 'desc', 'order_after' => $after, 'order_before' => $before, 'per_page' => $per_page, ) ) )->data : array(); $rows = array(); foreach ( $customers_data as $customer ) { $url_query = wp_parse_args( array( 'filter' => 'single_customer', 'customers' => $customer['id'], ), $persisted_query ); $customer_url = wc_admin_url( '/analytics/customers', $url_query ); $rows[] = array( array( 'display' => "<a href='{$customer_url}'>{$customer['name']}</a>", 'value' => $customer['name'], ), array( 'display' => wc_admin_number_format( $customer['orders_count'] ), 'value' => $customer['orders_count'], 'format' => 'number', ), array( 'display' => wc_price( $customer['total_spend'] ), 'value' => $customer['total_spend'], 'format' => 'currency', ), ); } return array( 'id' => 'customers', 'label' => __( 'Top Customers - Total Spend', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Customer Name', 'woocommerce' ), ), array( 'label' => __( 'Orders', 'woocommerce' ), ), array( 'label' => __( 'Total Spend', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the products leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_products_leaderboard( $per_page, $after, $before, $persisted_query ) { $products_data_store = new ProductsDataStore(); $products_data = $per_page > 0 ? $products_data_store->get_data( apply_filters( 'woocommerce_analytics_products_query_args', array( 'orderby' => 'items_sold', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $products_data as $product ) { $url_query = wp_parse_args( array( 'filter' => 'single_product', 'products' => $product['product_id'], ), $persisted_query ); $product_url = wc_admin_url( '/analytics/products', $url_query ); $product_name = isset( $product['extended_info'] ) && isset( $product['extended_info']['name'] ) ? $product['extended_info']['name'] : ''; $rows[] = array( array( 'display' => "<a href='{$product_url}'>{$product_name}</a>", 'value' => $product_name, ), array( 'display' => wc_admin_number_format( $product['items_sold'] ), 'value' => $product['items_sold'], 'format' => 'number', ), array( 'display' => wc_price( $product['net_revenue'] ), 'value' => $product['net_revenue'], 'format' => 'currency', ), ); } return array( 'id' => 'products', 'label' => __( 'Top products - Items sold', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Product', 'woocommerce' ), ), array( 'label' => __( 'Items sold', 'woocommerce' ), ), array( 'label' => __( 'Net sales', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get an array of all leaderboards. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. * @return array */ public function get_leaderboards( $per_page, $after, $before, $persisted_query ) { $leaderboards = array( $this->get_customers_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_categories_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_products_leaderboard( $per_page, $after, $before, $persisted_query ), ); return apply_filters( 'woocommerce_leaderboards', $leaderboards, $per_page, $after, $before, $persisted_query ); } /** * Return all leaderboards. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { $persisted_query = json_decode( $request['persisted_query'], true ); switch ( $request['leaderboard'] ) { case 'customers': $leaderboards = array( $this->get_customers_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'coupons': $leaderboards = array( $this->get_coupons_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'categories': $leaderboards = array( $this->get_categories_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'products': $leaderboards = array( $this->get_products_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; default: $leaderboards = $this->get_leaderboards( $request['per_page'], $request['after'], $request['before'], $persisted_query ); break; } $data = array(); if ( ! empty( $leaderboards ) ) { foreach ( $leaderboards as $leaderboard ) { $response = $this->prepare_item_for_response( $leaderboard, $request ); $data[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $data ); } /** * Returns a list of allowed leaderboards. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_allowed_items( $request ) { $leaderboards = $this->get_leaderboards( 0, null, null, null ); $data = array(); foreach ( $leaderboards as $leaderboard ) { $data[] = (object) array( 'id' => $leaderboard['id'], 'label' => $leaderboard['label'], 'headers' => $leaderboard['headers'], ); } $objects = array(); foreach ( $data as $item ) { $prepared = $this->prepare_item_for_response( $item, $request ); $objects[] = $this->prepare_response_for_collection( $prepared ); } $response = rest_ensure_response( $objects ); $response->header( 'X-WP-Total', count( $data ) ); $response->header( 'X-WP-TotalPages', 1 ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); return $response; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_leaderboard', $response, $item, $request ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 5, 'minimum' => 1, 'maximum' => 20, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['after'] = array( 'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['before'] = array( 'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['persisted_query'] = array( 'description' => __( 'URL query to persist across links.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'leaderboard', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'string', 'description' => __( 'Leaderboard ID.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'label' => array( 'type' => 'string', 'description' => __( 'Displayed title for the leaderboard.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'headers' => array( 'type' => 'array', 'description' => __( 'Table headers.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'items' => array( 'type' => 'array', 'properties' => array( 'label' => array( 'description' => __( 'Table column header.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ), ), 'rows' => array( 'type' => 'array', 'description' => __( 'Table rows.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'items' => array( 'type' => 'array', 'properties' => array( 'display' => array( 'description' => __( 'Table cell display.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'value' => array( 'description' => __( 'Table cell value.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'format' => array( 'description' => __( 'Table cell format.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'enum' => array( 'currency', 'number' ), 'readonly' => true, 'required' => false, ), ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get schema for the list of allowed leaderboards. * * @return array $schema */ public function get_public_allowed_item_schema() { $schema = $this->get_public_item_schema(); unset( $schema['properties']['rows'] ); return $schema; } } ProductAttributes.php 0000777 00000010730 15252174340 0010757 0 ustar 00 <?php /** * REST API Product Attributes Controller * * Handles requests to /products/attributes. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product categories controller. * * @internal * @extends WC_REST_Product_Attributes_Controller */ class ProductAttributes extends \WC_REST_Product_Attributes_Controller { use CustomAttributeTraits; /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for custom product attributes. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, 'products/attributes/(?P<slug>[a-z0-9_\-]+)', array( 'args' => array( 'slug' => array( 'description' => __( 'Slug identifier for the resource.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item_by_slug' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the query params for collections * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar attribute name.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Attribute's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); // Custom attributes substitute slugs for numeric IDs. $schema['properties']['id']['type'] = array( 'integer', 'string' ); return $schema; } /** * Get a single attribute by it's slug. * * @param WP_REST_Request $request The API request. * @return WP_REST_Response */ public function get_item_by_slug( $request ) { if ( empty( $request['slug'] ) ) { return array(); } $attributes = $this->get_custom_attribute_by_slug( $request['slug'] ); if ( is_wp_error( $attributes ) ) { return $attributes; } $response_items = $this->format_custom_attribute_items_for_response( $attributes ); return reset( $response_items ); } /** * Format custom attribute items for response (mimic the structure of a taxonomy - backed attribute). * * @param array $custom_attributes - CustomAttributeTraits::get_custom_attributes(). * @return array */ protected function format_custom_attribute_items_for_response( $custom_attributes ) { $response = array(); foreach ( $custom_attributes as $attribute_key => $attribute_value ) { $data = array( 'id' => $attribute_key, 'name' => $attribute_value['name'], 'slug' => $attribute_key, 'type' => 'select', 'order_by' => 'menu_order', 'has_archives' => false, ); $item_response = rest_ensure_response( $data ); $item_response->add_links( $this->prepare_links( (object) array( 'attribute_id' => $attribute_key ) ) ); $item_response = $this->prepare_response_for_collection( $item_response ); $response[] = $item_response; } return $response; } /** * Get all attributes, with support for searching (which includes custom attributes). * * @param WP_REST_Request $request The API request. * @return WP_REST_Response */ public function get_items( $request ) { if ( empty( $request['search'] ) ) { return parent::get_items( $request ); } $search_string = $request['search']; $custom_attributes = $this->get_custom_attributes( array( 'name' => $search_string ) ); $matching_attributes = $this->format_custom_attribute_items_for_response( $custom_attributes ); $taxonomy_attributes = wc_get_attribute_taxonomies(); foreach ( $taxonomy_attributes as $attribute_obj ) { // Skip taxonomy attributes that didn't match the query. if ( false === stripos( $attribute_obj->attribute_label, $search_string ) ) { continue; } $attribute = $this->prepare_item_for_response( $attribute_obj, $request ); $matching_attributes[] = $this->prepare_response_for_collection( $attribute ); } $response = rest_ensure_response( $matching_attributes ); $response->header( 'X-WP-Total', count( $matching_attributes ) ); $response->header( 'X-WP-TotalPages', 1 ); return $response; } } Experiments.php 0000777 00000003510 15252174340 0007571 0 ustar 00 <?php /** * REST API Experiment Controller * * Handles requests to /experiment */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data controller. * * @extends WC_REST_Data_Controller */ class Experiments extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'experiments'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/assignment', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_assignment' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Forward the experiment request to WP.com and return the WP.com response. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_assignment( $request ) { $args = $request->get_query_params(); if ( ! isset( $args['experiment_name'] ) ) { return new \WP_Error( 'woocommerce_rest_experiment_name_required', __( 'Sorry, experiment_name is required.', 'woocommerce' ), array( 'status' => 400 ) ); } unset( $args['rest_route'] ); $abtest = new \WooCommerce\Admin\Experimental_Abtest( $request->get_param( 'anon_id' ) ?? '', 'woocommerce', true, // set consent to true here since frontend has checked it already. true // set true to send request as auth user. ); $response = $abtest->request_assignment( $args ); if ( is_wp_error( $response ) ) { return $response; } return json_decode( $response['body'], true ); } } Customers.php 0000777 00000004163 15252174340 0007257 0 ustar 00 <?php /** * REST API Customers Controller * * Handles requests to /customers/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Customers controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller */ class Customers extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller { /** * Route base. * * @var string */ protected $rest_base = 'customers'; /** * Register the routes for customers. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d-]+)', array( 'args' => array( 'id' => array( 'description' => __( 'Unique ID for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = parent::prepare_reports_query( $request ); $args['customers'] = $request['include']; return $args; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['include'] = $params['customers']; unset( $params['customers'] ); return $params; } } CustomAttributeTraits.php 0000777 00000006634 15252174340 0011625 0 ustar 00 <?php /** * Traits for handling custom product attributes and their terms. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * CustomAttributeTraits class. * * @internal */ trait CustomAttributeTraits { /** * Get a single attribute by its slug. * * @internal * @param string $slug The attribute slug. * @return WP_Error|object The matching attribute object or WP_Error if not found. */ public function get_custom_attribute_by_slug( $slug ) { $matching_attributes = $this->get_custom_attributes( array( 'slug' => $slug ) ); if ( empty( $matching_attributes ) ) { return new \WP_Error( 'woocommerce_rest_product_attribute_not_found', __( 'No product attribute with that slug was found.', 'woocommerce' ), array( 'status' => 404 ) ); } foreach ( $matching_attributes as $attribute_key => $attribute_value ) { return array( $attribute_key => $attribute_value ); } } /** * Query custom attributes by name or slug. * * @param string $args Search arguments, either name or slug. * @return array Matching attributes, formatted for response. */ protected function get_custom_attributes( $args ) { global $wpdb; $args = wp_parse_args( $args, array( 'name' => '', 'slug' => '', ) ); if ( empty( $args['name'] ) && empty( $args['slug'] ) ) { return array(); } $mode = $args['name'] ? 'name' : 'slug'; if ( 'name' === $mode ) { $name = $args['name']; // Get as close as we can to matching the name property of custom attributes using SQL. $like = '%"name";s:%:"%' . $wpdb->esc_like( $name ) . '%"%'; } else { $slug = sanitize_title_for_query( $args['slug'] ); // Get as close as we can to matching the slug property of custom attributes using SQL. $like = '%s:' . strlen( $slug ) . ':"' . $slug . '";a:6:{%'; } // Find all serialized product attributes with names like the search string. $query_results = $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_product_attributes' AND meta_value LIKE %s LIMIT 100", $like ), ARRAY_A ); $custom_attributes = array(); foreach ( $query_results as $raw_product_attributes ) { $meta_attributes = maybe_unserialize( $raw_product_attributes['meta_value'] ); if ( empty( $meta_attributes ) || ! is_array( $meta_attributes ) ) { continue; } foreach ( $meta_attributes as $meta_attribute_key => $meta_attribute_value ) { $meta_value = array_merge( array( 'name' => '', 'is_taxonomy' => 0, ), (array) $meta_attribute_value ); // Skip non-custom attributes. if ( ! empty( $meta_value['is_taxonomy'] ) ) { continue; } // Skip custom attributes that didn't match the query. // (There can be any number of attributes in the meta value). if ( ( 'name' === $mode ) && ( false === stripos( $meta_value['name'], $name ) ) ) { continue; } if ( ( 'slug' === $mode ) && ( $meta_attribute_key !== $slug ) ) { continue; } // Combine all values when there are multiple matching custom attributes. if ( isset( $custom_attributes[ $meta_attribute_key ] ) ) { $custom_attributes[ $meta_attribute_key ]['value'] .= ' ' . WC_DELIMITER . ' ' . $meta_value['value']; } else { $custom_attributes[ $meta_attribute_key ] = $meta_attribute_value; } } } return $custom_attributes; } } Features.php 0000777 00000003314 15252174340 0007046 0 ustar 00 <?php /** * REST API Features Controller * * Handles requests to /features */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Features\Features as FeaturesClass; /** * Features Controller. * * @internal * @extends WC_REST_Data_Controller */ class Features extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'features'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_features' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available payment methods. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_features( $request ) { return FeaturesClass::get_available_features(); } } DataCountries.php 0000777 00000002175 15252174340 0010041 0 ustar 00 <?php /** * REST API Data countries controller. * * Handles requests to the /data/countries endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * REST API Data countries controller class. * * @internal * @extends WC_REST_Data_Countries_Controller */ class DataCountries extends \WC_REST_Data_Countries_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register routes. * * @since 3.5.0 */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/locales', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_locales' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); parent::register_routes(); } /** * Get country fields. * * @return array */ public function get_locales() { $locales = WC()->countries->get_country_locale(); return rest_ensure_response( $locales ); } } AI/BusinessDescription.php 0000777 00000000453 15252174340 0011561 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Title controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class BusinessDescription {} AI/Middleware.php 0000777 00000000376 15252174340 0007643 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; /** * Middleware class. * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Middleware {} AI/StoreTitle.php 0000777 00000000442 15252174340 0007656 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Title controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class StoreTitle {} AI/Product.php 0000777 00000000433 15252174340 0007200 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Product controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Product {} AI/StoreInfo.php 0000777 00000000440 15252174340 0007466 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Info controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class StoreInfo {} AI/Images.php 0000777 00000000431 15252174340 0006763 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Images controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Images {} AI/Patterns.php 0000777 00000000435 15252174340 0007362 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Patterns controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Patterns {} OnboardingFreeExtensions.php 0000777 00000005117 15252174340 0012237 0 ustar 00 <?php /** * REST API Onboarding Free Extensions Controller * * Handles requests to /onboarding/free-extensions */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init as RemoteFreeExtensions; use WC_REST_Data_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; /** * Onboarding Payments Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingFreeExtensions extends WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/free-extensions'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_available_extensions' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available payment methods. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_available_extensions( $request ) { $extensions = RemoteFreeExtensions::get_extensions(); /** * Allows removing Jetpack suggestions from WooCommerce Admin when false. * * In this instance it is removed from the list of extensions suggested in the Onboarding Profiler. This list is first retrieved from the WooCommerce.com API, then if a plugin with the 'jetpack' slug is found, it is removed. * * @since 7.8 */ if ( false === apply_filters( 'woocommerce_suggest_jetpack', true ) ) { foreach ( $extensions as &$extension ) { $extension['plugins'] = array_filter( $extension['plugins'], function( $plugin ) { return 'jetpack' !== $plugin->key; } ); } } return new WP_REST_Response( $extensions ); } }
| ver. 1.6 |
Github
|
.
| PHP 8.1.34 | Генерация страницы: 0.02 |
proxy
|
phpinfo
|
Настройка