PluginProbe
ElasticPress / 4.6.1
ElasticPress v4.6.1
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.1, at includes/classes/Feature/WooCommerce/Orders.php

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