PluginProbe
ElasticPress / 4.6.0
ElasticPress v4.6.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Feature / WooCommerce / Orders.php

Orders.php in ElasticPress 4.6.0, at includes/classes/Feature/WooCommerce/Orders.php

606 lines 16.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Orders Feature
4 *
5 * @since 4.5.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Feature\WooCommerce;
10
11 use ElasticPress\Elasticsearch;
12 use ElasticPress\Indexables;
13 use ElasticPress\Utils;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * WooCommerce Orders Feature
21 */
22 class Orders {
23 /**
24 * Initialize feature.
25 *
26 * @return void
27 */
28 public function __construct() {
29 $this->index = Indexables::factory()->get( 'post' )->get_index_name();
30 }
31
32 /**
33 * Setup feature functionality.
34 *
35 * @return void
36 */
37 public function setup() {
38 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
39 add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 );
40 add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] );
41 add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] );
42 add_filter( 'ep_indexable_post_status', [ $this, 'post_statuses' ] );
43 add_filter( 'ep_indexable_post_types', [ $this, 'post_types' ] );
44 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
45 add_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ], 10 );
46 add_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
47 add_action( 'ep_woocommerce_shop_order_search_fields', [ $this, 'set_search_fields' ], 10, 2 );
48 add_filter( 'ep_index_posts_args', [ $this, 'maybe_query_password_protected_posts' ] );
49 add_filter( 'posts_where', [ $this, 'maybe_set_posts_where' ], 10, 2 );
50 }
51
52 /**
53 * Get the endpoint for WooCommerce Orders search.
54 *
55 * @return string WooCommerce orders search endpoint.
56 */
57 public function get_search_endpoint() {
58 /**
59 * Filters the WooCommerce Orders search endpoint.
60 *
61 * @since 4.5.0
62 * @hook ep_woocommerce_order_search_endpoint
63 * @param {string} $endpoint Endpoint path.
64 * @param {string} $index Elasticsearch index.
65 */
66 return apply_filters( 'ep_woocommerce_order_search_endpoint', "api/v1/search/orders/{$this->index}", $this->index );
67 }
68
69 /**
70 * Get the endpoint for the WooCommerce Orders search template.
71 *
72 * @return string WooCommerce Orders search template endpoint.
73 */
74 public function get_template_endpoint() {
75 /**
76 * Filters the WooCommerce Orders search template API endpoint.
77 *
78 * @since 4.5.0
79 * @hook ep_woocommerce_order_search_template_endpoint
80 * @param {string} $endpoint Endpoint path.
81 * @param {string} $index Elasticsearch index.
82 * @returns {string} Search template API endpoint.
83 */
84 return apply_filters( 'ep_woocommerce_order_search_template_endpoint', "api/v1/search/orders/{$this->index}/template", $this->index );
85 }
86
87 /**
88 * Get the endpoint for temporary tokens.
89 *
90 * @return string Temporary token endpoint.
91 */
92 public function get_token_endpoint() {
93 /**
94 * Filters the temporary token API endpoint.
95 *
96 * @since 4.5.0
97 * @hook ep_token_endpoint
98 * @param {string} $endpoint Endpoint path.
99 * @returns {string} Token API endpoint.
100 */
101 return apply_filters( 'ep_token_endpoint', 'api/v1/token' );
102 }
103
104 /**
105 * Registers the API endpoint to get a token.
106 *
107 * @return void
108 */
109 public function rest_api_init() {
110 register_rest_route(
111 'elasticpress/v1',
112 'token',
113 [
114 [
115 'callback' => [ $this, 'get_token' ],
116 'permission_callback' => [ $this, 'check_token_permission' ],
117 'methods' => 'GET',
118 ],
119 [
120 'callback' => [ $this, 'refresh_token' ],
121 'permission_callback' => [ $this, 'check_token_permission' ],
122 'methods' => 'POST',
123 ],
124 ]
125 );
126 }
127
128 /**
129 * Enqueue admin assets.
130 *
131 * @param string $hook_suffix The current admin page.
132 */
133 public function enqueue_admin_assets( $hook_suffix ) {
134 if ( 'edit.php' !== $hook_suffix ) {
135 return;
136 }
137
138 if ( ! isset( $_GET['post_type'] ) || 'shop_order' !== $_GET['post_type'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
139 return;
140 }
141
142 wp_enqueue_style(
143 'elasticpress-woocommerce-order-search',
144 EP_URL . 'dist/css/woocommerce-order-search-styles.css',
145 Utils\get_asset_info( 'woocommerce-order-search-styles', 'dependencies' ),
146 Utils\get_asset_info( 'woocommerce-order-search-styles', 'version' )
147 );
148
149 wp_enqueue_script(
150 'elasticpress-woocommerce-order-search',
151 EP_URL . 'dist/js/woocommerce-order-search-script.js',
152 Utils\get_asset_info( 'woocommerce-order-search-script', 'dependencies' ),
153 Utils\get_asset_info( 'woocommerce-order-search-script', 'version' ),
154 true
155 );
156
157 wp_set_script_translations( 'elasticpress-woocommerce-order-search', 'elasticpress' );
158
159 $api_endpoint = $this->get_search_endpoint();
160 $api_host = Utils\get_host();
161
162 wp_localize_script(
163 'elasticpress-woocommerce-order-search',
164 'epWooCommerceOrderSearch',
165 array(
166 'adminUrl' => admin_url( 'post.php' ),
167 'apiEndpoint' => $api_endpoint,
168 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? trailingslashit( esc_url_raw( $api_host ) ) : '',
169 'argsSchema' => $this->get_args_schema(),
170 'credentialsApiUrl' => rest_url( 'elasticpress/v1/token' ),
171 'credentialsNonce' => wp_create_nonce( 'wp_rest' ),
172 'dateFormat' => wc_date_format(),
173 'statusLabels' => wc_get_order_statuses(),
174 'timeFormat' => wc_time_format(),
175 'requestIdBase' => Utils\get_request_id_base(),
176 )
177 );
178 }
179
180 /**
181 * Save or delete the search template on ElasticPress.io based on whether
182 * the WooCommerce feature is being activated or deactivated.
183 *
184 * @param string $feature Feature slug
185 * @param array $settings Feature settings
186 * @param array $data Feature activation data
187 *
188 * @return void
189 */
190 public function after_update_feature( $feature, $settings, $data ) {
191 if ( 'woocommerce' !== $feature ) {
192 return;
193 }
194
195 if ( true === $data['active'] ) {
196 $this->epio_save_search_template();
197 } else {
198 $this->epio_delete_search_template();
199 }
200 }
201
202 /**
203 * Save the search template to ElasticPress.io.
204 *
205 * @return void
206 */
207 public function epio_save_search_template() {
208 $endpoint = $this->get_template_endpoint();
209 $template = $this->get_search_template();
210
211 Elasticsearch::factory()->remote_request(
212 $endpoint,
213 [
214 'blocking' => false,
215 'body' => $template,
216 'method' => 'PUT',
217 ]
218 );
219
220 /**
221 * Fires after the request is sent the search template API endpoint.
222 *
223 * @since 4.5.0
224 * @hook ep_woocommerce_order_search_template_saved
225 * @param {string} $template The search template (JSON).
226 * @param {string} $index Index name.
227 */
228 do_action( 'ep_woocommerce_order_search_template_saved', $template, $this->index );
229 }
230
231 /**
232 * Delete the search template from ElasticPress.io.
233 *
234 * @return void
235 */
236 public function epio_delete_search_template() {
237 $endpoint = $this->get_template_endpoint();
238
239 Elasticsearch::factory()->remote_request(
240 $endpoint,
241 [
242 'blocking' => false,
243 'method' => 'DELETE',
244 ]
245 );
246
247 /**
248 * Fires after the request is sent the search template API endpoint.
249 *
250 * @since 4.5.0
251 * @hook ep_woocommerce_order_search_template_deleted
252 * @param {string} $index Index name.
253 */
254 do_action( 'ep_woocommerce_order_search_template_deleted', $this->index );
255 }
256
257 /**
258 * Get the saved search template from ElasticPress.io.
259 *
260 * @return string|WP_Error Search template if found, WP_Error on error.
261 */
262 public function epio_get_search_template() {
263 $endpoint = $this->get_template_endpoint();
264 $request = Elasticsearch::factory()->remote_request( $endpoint );
265
266 if ( is_wp_error( $request ) ) {
267 return $request;
268 }
269
270 $response = wp_remote_retrieve_body( $request );
271
272 return $response;
273 }
274
275 /**
276 * Generate a search template.
277 *
278 * A search template is the JSON for an Elasticsearch query with a
279 * placeholder search term. The template is sent to ElasticPress.io where
280 * it's used to make Elasticsearch queries using search terms sent from
281 * the front end.
282 *
283 * @return string The search template as JSON.
284 */
285 public function get_search_template() {
286 $order_statuses = wc_get_order_statuses();
287
288 add_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
289 add_filter( 'ep_intercept_remote_request', '__return_true' );
290 add_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10, 4 );
291 add_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10, 2 );
292
293 $query = new \WP_Query(
294 array(
295 'ep_integrate' => true,
296 'ep_order_search_template' => true,
297 'post_status' => array_keys( $order_statuses ),
298 'post_type' => 'shop_order',
299 's' => '{{ep_placeholder}}',
300 )
301 );
302
303 remove_filter( 'ep_bypass_exclusion_from_search', '__return_true', 10 );
304 remove_filter( 'ep_intercept_remote_request', '__return_true' );
305 remove_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10 );
306 remove_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10 );
307
308 return $this->search_template;
309 }
310
311 /**
312 * Return true if a given feature is supported by WooCommerce Orders.
313 *
314 * Applied as a filter on Utils\is_integrated_request() so that features
315 * are enabled for the query that is used to generate the search template,
316 * regardless of the request type. This avoids the need to send a request
317 * to the front end.
318 *
319 * @param bool $is_integrated Whether queries for the request will be
320 * integrated.
321 * @param string $context Context for the original check. Usually the
322 * slug of the feature doing the check.
323 * @return bool True if the check is for a feature supported by WooCommerce
324 * Order search.
325 */
326 public function is_integrated_request( $is_integrated, $context ) {
327 $supported_contexts = [
328 'search',
329 'woocommerce',
330 ];
331
332 return in_array( $context, $supported_contexts, true );
333 }
334
335 /**
336 * Store intercepted request body and return request result.
337 *
338 * @param object $response Response
339 * @param array $query Query
340 * @param array $args WP_Query argument array
341 * @param int $failures Count of failures in request loop
342 * @return object $response Response
343 */
344 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
345 $this->search_template = $query['args']['body'];
346
347 return wp_remote_request( $query['url'], $args );
348 }
349
350 /**
351 * Get schema for search args.
352 *
353 * @return array Search args schema.
354 */
355 public function get_args_schema() {
356 $args = array(
357 'customer' => array(
358 'type' => 'number',
359 ),
360 'm' => array(
361 'type' => 'string',
362 ),
363 'offset' => array(
364 'type' => 'number',
365 'default' => 0,
366 ),
367 'per_page' => array(
368 'type' => 'number',
369 'default' => 6,
370 ),
371 'search' => array(
372 'type' => 'string',
373 'default' => '',
374 ),
375 );
376
377 return $args;
378 }
379
380 /**
381 * Get a temporary token.
382 *
383 * @return string|false Authorization header, or false on failure.
384 */
385 public function get_token() {
386 $user_id = get_current_user_id();
387
388 $credentials = get_user_meta( $user_id, 'ep_token', true );
389
390 if ( $credentials ) {
391 return $credentials;
392 }
393
394 return $this->refresh_token();
395 }
396
397 /**
398 * Refresh the temporary token.
399 *
400 * @return string|false Authorization header, or false on failure.
401 */
402 public function refresh_token() {
403 $user_id = get_current_user_id();
404
405 $endpoint = $this->get_token_endpoint();
406 $response = Elasticsearch::factory()->remote_request( $endpoint, [ 'method' => 'POST' ] );
407
408 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
409 return false;
410 }
411
412 $response = wp_remote_retrieve_body( $response );
413 $response = json_decode( $response );
414
415 $credentials = base64_encode( "$response->username:$response->clear_password" ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
416
417 update_user_meta( $user_id, 'ep_token', $credentials );
418
419 return $credentials;
420 }
421
422 /**
423 * Checks if the token API can be used.
424 *
425 * @return boolean Whether the token API can be used.
426 */
427 public function check_token_permission() {
428 /**
429 * Filters the capability required to use the token API.
430 *
431 * @since 4.5.0
432 * @hook ep_token_capability
433 * @param {string} $capability Required capability.
434 */
435 $capability = apply_filters( 'ep_token_capability', 'edit_others_shop_orders' );
436
437 return current_user_can( $capability );
438 }
439
440 /**
441 * Index shop orders.
442 *
443 * @param array $post_types Indexable post types.
444 * @return array Indexable post types.
445 */
446 public function post_types( $post_types ) {
447 $post_types['shop_order'] = 'shop_order';
448
449 return $post_types;
450 }
451
452 /**
453 * Index order statuses.
454 *
455 * @param array $post_statuses Indexable post statuses.
456 * @return array Indexable post statuses.
457 */
458 public function post_statuses( $post_statuses ) {
459 $order_statuses = wc_get_order_statuses();
460
461 return array_unique( array_merge( $post_statuses, array_keys( $order_statuses ) ) );
462 }
463
464 /**
465 * Add term suggestions to be indexed
466 *
467 * @param array $post_args Array of ES args.
468 * @return array
469 */
470 public function filter_term_suggest( $post_args ) {
471 if ( empty( $post_args['post_type'] ) || 'shop_order' !== $post_args['post_type'] ) {
472 return $post_args;
473 }
474
475 if ( empty( $post_args['meta'] ) ) {
476 return $post_args;
477 }
478
479 /**
480 * Add the order number as a meta (text) field, so we can freely search on it.
481 */
482 $order_id = $post_args['ID'];
483 if ( function_exists( 'wc_get_order' ) ) {
484 $order = wc_get_order( $post_args['ID'] );
485 if ( $order && is_a( $order, 'WC_Order' ) && method_exists( $order, 'get_order_number' ) ) {
486 $order_id = $order->get_order_number();
487 }
488 }
489
490 $post_args['meta']['order_number'] = [
491 [
492 'raw' => $order_id,
493 'value' => $order_id,
494 ],
495 ];
496
497 $suggest = [];
498
499 $fields_to_ngram = [
500 '_billing_email',
501 '_billing_last_name',
502 '_billing_first_name',
503 ];
504
505 foreach ( $fields_to_ngram as $field_to_ngram ) {
506 if ( ! empty( $post_args['meta'][ $field_to_ngram ] )
507 && ! empty( $post_args['meta'][ $field_to_ngram ][0] )
508 && ! empty( $post_args['meta'][ $field_to_ngram ][0]['value'] ) ) {
509 $suggest[] = $post_args['meta'][ $field_to_ngram ][0]['value'];
510 }
511 }
512
513 if ( ! empty( $suggest ) ) {
514 $post_args['term_suggest'] = $suggest;
515 }
516
517 return $post_args;
518 }
519
520 /**
521 * Add mapping for suggest fields
522 *
523 * @param array $mapping ES mapping.
524 * @return array
525 */
526 public function mapping( $mapping ) {
527 $post_indexable = Indexables::factory()->get( 'post' );
528
529 $mapping = $post_indexable->add_ngram_analyzer( $mapping );
530 $mapping = $post_indexable->add_term_suggest_field( $mapping );
531
532 return $mapping;
533 }
534
535 /**
536 * Set the search_fields parameter in the search template.
537 *
538 * @param array $search_fields Current search fields
539 * @param \WP_Query $query Query being executed
540 * @return array New search fields
541 */
542 public function set_search_fields( array $search_fields, \WP_Query $query ) : array {
543 $is_orders_search_template = (bool) $query->get( 'ep_order_search_template' );
544
545 if ( $is_orders_search_template ) {
546 $search_fields = [
547 'meta.order_number.value',
548 'term_suggest',
549 'meta' => [
550 '_billing_email',
551 '_billing_last_name',
552 '_billing_first_name',
553 ],
554 ];
555 }
556
557 return $search_fields;
558 }
559
560 /**
561 * Allow password protected to be indexed.
562 *
563 * If Protected Content is enabled, do nothing. Otherwise, allow pw protected posts to be indexed.
564 * The feature restricts it back in maybe_set_posts_where()
565 *
566 * @see maybe_set_posts_where()
567 * @param array $args WP_Query args
568 * @return array
569 */
570 public function maybe_query_password_protected_posts( $args ) {
571 // Password protected posts are already being indexed, no need to do anything.
572 if ( isset( $args['has_password'] ) && is_null( $args['has_password'] ) ) {
573 return $args;
574 }
575
576 /**
577 * Set a flag in the query but allow it to index all password protected posts for now,
578 * so WP does not inject its own where clause.
579 */
580 $args['ep_orders_has_password'] = true;
581 $args['has_password'] = null;
582
583 return $args;
584 }
585
586 /**
587 * Restrict password protected posts back but allow orders.
588 *
589 * @see maybe_query_password_protected_posts
590 * @param string $where Current where clause
591 * @param WP_Query $query WP_Query
592 * @return string
593 */
594 public function maybe_set_posts_where( $where, $query ) {
595 global $wpdb;
596
597 if ( ! $query->get( 'ep_orders_has_password' ) ) {
598 return $where;
599 }
600
601 $where .= " AND ( {$wpdb->posts}.post_password = '' OR {$wpdb->posts}.post_type = 'shop_order' )";
602
603 return $where;
604 }
605 }
606