Fixes some minor process issues (https://github.com/woocommerce/woocommerce-admin/pull/8355)
* Additional general updates and improvements * Fix php lint errors
This commit is contained in:
parent
ed2a1eaae2
commit
e652b0b93f
|
@ -44,7 +44,7 @@ class MerchantEmailNotifications {
|
|||
|
||||
$note = Notes::get_note( $note_id );
|
||||
|
||||
if ( ! $note ) {
|
||||
if ( ! $note || Note::E_WC_ADMIN_NOTE_EMAIL !== $note->get_type() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -119,12 +119,17 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
*/
|
||||
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} = _terms.term_id";
|
||||
$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
|
||||
|
|
|
@ -115,9 +115,14 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
*/
|
||||
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} = _coupons.ID";
|
||||
$join = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell_identifier} = _coupons.ID";
|
||||
$this->add_orderby_order_clause( $query_args, $this );
|
||||
|
||||
if ( 'inner' === $from_arg ) {
|
||||
|
|
|
@ -1338,6 +1338,8 @@ class DataStore extends SqlQuery {
|
|||
* @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();
|
||||
|
||||
|
@ -1354,7 +1356,10 @@ class DataStore extends SqlQuery {
|
|||
$ids = apply_filters( 'woocommerce_analytics_' . $field, $ids, $query_args, $field, $this->context );
|
||||
|
||||
if ( ! empty( $ids ) ) {
|
||||
$ids_str = implode( $separator, $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;
|
||||
}
|
||||
|
|
|
@ -188,10 +188,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
* @return string
|
||||
*/
|
||||
protected function get_included_ip_addresses( $query_args ) {
|
||||
if ( isset( $query_args['ip_address_includes'] ) && is_array( $query_args['ip_address_includes'] ) && count( $query_args['ip_address_includes'] ) > 0 ) {
|
||||
$query_args['ip_address_includes'] = array_map( 'esc_sql', $query_args['ip_address_includes'] );
|
||||
}
|
||||
return self::get_filtered_ids( $query_args, 'ip_address_includes', "','" );
|
||||
return $this->get_filtered_ip_addresses( $query_args, 'ip_address_includes' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -201,10 +198,35 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
* @return string
|
||||
*/
|
||||
protected function get_excluded_ip_addresses( $query_args ) {
|
||||
if ( isset( $query_args['ip_address_excludes'] ) && is_array( $query_args['ip_address_excludes'] ) && count( $query_args['ip_address_excludes'] ) > 0 ) {
|
||||
$query_args['ip_address_excludes'] = array_map( 'esc_sql', $query_args['ip_address_excludes'] );
|
||||
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 self::get_filtered_ids( $query_args, 'ip_address_excludes', "','" );
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -260,7 +282,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
$this->clear_sql_clause( 'order_by' );
|
||||
$order_by = '';
|
||||
if ( isset( $query_args['orderby'] ) ) {
|
||||
$order_by = $this->normalize_order_by( $query_args['orderby'] );
|
||||
$order_by = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) );
|
||||
$this->add_sql_clause( 'order_by', $order_by );
|
||||
}
|
||||
|
||||
|
@ -315,11 +337,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
$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"
|
||||
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
$params = $this->get_limit_params( $query_args );
|
||||
$total_pages = (int) ceil( $db_records_count / $params['per_page'] );
|
||||
|
@ -333,9 +357,9 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
$this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );
|
||||
|
||||
$download_data = $wpdb->get_results(
|
||||
$this->subquery->get_query_statement(),
|
||||
$this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
ARRAY_A
|
||||
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
|
||||
);
|
||||
|
||||
if ( null === $download_data ) {
|
||||
return $data;
|
||||
|
|
|
@ -169,8 +169,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
$where_subquery[] = 'variation_lookup.order_id IS NULL';
|
||||
}
|
||||
|
||||
$included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', $query_args['tax_rate_includes'] ) : false;
|
||||
$excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', $query_args['tax_rate_excludes'] ) : false;
|
||||
$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" );
|
||||
}
|
||||
|
|
|
@ -71,12 +71,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
* @param array $query_args Query arguments supplied by the user.
|
||||
*/
|
||||
protected function update_sql_query_params( $query_args ) {
|
||||
global $wpdb;
|
||||
|
||||
$taxes_where_clause = '';
|
||||
$order_tax_lookup_table = self::get_db_table_name();
|
||||
|
||||
if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
|
||||
$allowed_taxes = implode( ',', $query_args['taxes'] );
|
||||
$taxes_where_clause .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})";
|
||||
$tax_id_placeholders = implode( ',', array_fill( 0, count( $query_args['taxes'] ), '%d' ) );
|
||||
/* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
|
||||
$taxes_where_clause .= $wpdb->prepare( " AND {$order_tax_lookup_table}.tax_rate_id IN ({$tax_id_placeholders})", $query_args['taxes'] );
|
||||
/* phpcs:enable */
|
||||
}
|
||||
|
||||
$order_status_filter = $this->get_status_subquery( $query_args );
|
||||
|
@ -111,8 +115,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
|
|||
FROM {$wpdb->prefix}woocommerce_tax_rates
|
||||
";
|
||||
if ( ! empty( $args['include'] ) ) {
|
||||
$included_taxes = implode( ',', $args['include'] );
|
||||
$query .= " WHERE tax_rate_id IN ({$included_taxes})";
|
||||
/* 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.
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ class Segmenter extends ReportsSegmenter {
|
|||
|
||||
// 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
|
||||
|
@ -71,7 +72,8 @@ class Segmenter extends ReportsSegmenter {
|
|||
GROUP BY
|
||||
$segmenting_groupby",
|
||||
ARRAY_A
|
||||
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
|
||||
);
|
||||
/* phpcs:enable */
|
||||
|
||||
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
|
||||
return $totals_segments;
|
||||
|
@ -105,6 +107,7 @@ class Segmenter extends ReportsSegmenter {
|
|||
|
||||
// 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,
|
||||
|
@ -122,8 +125,9 @@ class Segmenter extends ReportsSegmenter {
|
|||
GROUP BY
|
||||
time_interval, $segmenting_groupby
|
||||
$segmenting_limit",
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
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;
|
||||
|
|
|
@ -40,7 +40,7 @@ class PaymentGatewaysController {
|
|||
? $gateway->get_settings_url()
|
||||
: admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . strtolower( $gateway->id ) );
|
||||
|
||||
$return_url = wc_admin_url( '&task=payments&connection-return=' . strtolower( $gateway->id ) );
|
||||
$return_url = wc_admin_url( '&task=payments&connection-return=' . strtolower( $gateway->id ) . '&_wpnonce=' . wp_create_nonce( 'connection-return' ) );
|
||||
$data['connection_url'] = method_exists( $gateway, 'get_connection_url' )
|
||||
? $gateway->get_connection_url( $return_url )
|
||||
: null;
|
||||
|
@ -85,21 +85,20 @@ class PaymentGatewaysController {
|
|||
* Call an action after a gating has been successfully returned.
|
||||
*/
|
||||
public static function possibly_do_connection_return_action() {
|
||||
// phpcs:disable WordPress.Security.NonceVerification
|
||||
if (
|
||||
! isset( $_GET['page'] ) ||
|
||||
'wc-admin' !== $_GET['page'] ||
|
||||
! isset( $_GET['task'] ) ||
|
||||
'payments' !== $_GET['task'] ||
|
||||
! isset( $_GET['connection-return'] )
|
||||
! isset( $_GET['connection-return'] ) ||
|
||||
! isset( $_GET['_wpnonce'] ) ||
|
||||
! wp_verify_nonce( wc_clean( wp_unslash( $_GET['_wpnonce'] ) ), 'connection-return' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway_id = sanitize_text_field( wp_unslash( $_GET['connection-return'] ) );
|
||||
|
||||
// phpcs:enable WordPress.Security.NonceVerification
|
||||
|
||||
do_action( 'woocommerce_admin_payment_gateway_connection_return', $gateway_id );
|
||||
}
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ class DeactivatePlugin {
|
|||
Note::E_WC_ADMIN_NOTE_UNACTIONED,
|
||||
true
|
||||
);
|
||||
$note->add_nonce_to_action( 'deactivate-feature-plugin', 'deactivate-plugin_' . WC_ADMIN_PLUGIN_FILE, '' );
|
||||
return $note;
|
||||
}
|
||||
|
||||
|
@ -64,7 +65,6 @@ class DeactivatePlugin {
|
|||
* Deactivate feature plugin.
|
||||
*/
|
||||
public function deactivate_feature_plugin() {
|
||||
/* phpcs:disable WordPress.Security.NonceVerification */
|
||||
if (
|
||||
! isset( $_GET['page'] ) ||
|
||||
'wc-admin' !== $_GET['page'] ||
|
||||
|
@ -74,10 +74,35 @@ class DeactivatePlugin {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
/* phpcs:enable */
|
||||
|
||||
$deactivate_url = admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( WC_ADMIN_PLUGIN_FILE ) . '&plugin_status=all&paged=1&_wpnonce=' . wp_create_nonce( 'deactivate-plugin_' . WC_ADMIN_PLUGIN_FILE ) );
|
||||
$note = self::get_note();
|
||||
$action = $note->get_action( 'deactivate-feature-plugin' );
|
||||
|
||||
// Preserve compatability with notes populated before nonce implementation.
|
||||
if ( ! isset( $_GET['_wpnonce'] ) && ( ! $action || ! isset( $action->nonce_action ) ) ) {
|
||||
self::deactivate_redirect( wp_create_nonce( 'deactivate-plugin_' . WC_ADMIN_PLUGIN_FILE ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
|
||||
|
||||
if ( ! wp_verify_nonce( $nonce, 'deactivate-plugin_' . WC_ADMIN_PLUGIN_FILE ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::deactivate_redirect( $nonce );
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivation redirect
|
||||
*
|
||||
* @param string $nonce The nonce.
|
||||
*/
|
||||
public static function deactivate_redirect( $nonce ) {
|
||||
|
||||
$deactivate_url = admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( WC_ADMIN_PLUGIN_FILE ) . '&plugin_status=all&paged=1&_wpnonce=' . $nonce );
|
||||
wp_safe_redirect( $deactivate_url );
|
||||
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -295,6 +295,25 @@ class Note extends \WC_Data {
|
|||
return $this->get_prop( 'actions', $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action by action name on the note.
|
||||
*
|
||||
* @param string $action_name The action name.
|
||||
* @param string $context What the value is for. Valid values are 'view' and 'edit'.
|
||||
* @return array the action.
|
||||
*/
|
||||
public function get_action( $action_name, $context = 'view' ) {
|
||||
$actions = $this->get_prop( 'actions', $context );
|
||||
|
||||
$matching_action = null;
|
||||
foreach ( $actions as $i => $action ) {
|
||||
if ( $action->name === $action_name ) {
|
||||
$matching_action =& $actions[ $i ];
|
||||
}
|
||||
}
|
||||
return $matching_action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get note layout (the old notes won't have one).
|
||||
*
|
||||
|
|
|
@ -116,6 +116,7 @@ class WooCommercePayments {
|
|||
$note->set_source( 'woocommerce-admin' );
|
||||
$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce-admin' ), 'https://woocommerce.com/payments/?utm_medium=product', Note::E_WC_ADMIN_NOTE_UNACTIONED );
|
||||
$note->add_action( 'get-started', __( 'Get started', 'woocommerce-admin' ), wc_admin_url( '&action=setup-woocommerce-payments' ), Note::E_WC_ADMIN_NOTE_ACTIONED, true );
|
||||
$note->add_nonce_to_action( 'get-started', 'setup-woocommerce-payments', '' );
|
||||
|
||||
// Create the note as "actioned" if the plugin is already installed.
|
||||
if ( self::is_installed() ) {
|
||||
|
@ -165,7 +166,6 @@ class WooCommercePayments {
|
|||
*/
|
||||
public function install_on_action() {
|
||||
// TODO: Need to validate this request more strictly since we're taking install actions directly?
|
||||
/* phpcs:disable WordPress.Security.NonceVerification */
|
||||
if (
|
||||
! isset( $_GET['page'] ) ||
|
||||
'wc-admin' !== $_GET['page'] ||
|
||||
|
@ -174,7 +174,19 @@ class WooCommercePayments {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
/* phpcs:enable */
|
||||
|
||||
$note = self::get_note();
|
||||
$action = $note->get_action( 'get-started' );
|
||||
if ( ! $action ||
|
||||
( isset( $action->nonce_action ) &&
|
||||
(
|
||||
empty( $_GET['_wpnonce'] ) ||
|
||||
! wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action->nonce_action ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'install_plugins' ) ) {
|
||||
return;
|
||||
|
|
|
@ -29,10 +29,21 @@ class PluginsInstaller {
|
|||
*/
|
||||
public static function possibly_install_activate_plugins() {
|
||||
/* phpcs:disable WordPress.Security.NonceVerification.Recommended */
|
||||
if ( ! isset( $_GET['plugin_action'] ) || ! isset( $_GET['plugins'] ) || ! current_user_can( 'install_plugins' ) ) {
|
||||
if (
|
||||
! isset( $_GET['plugin_action'] ) ||
|
||||
! isset( $_GET['plugins'] ) ||
|
||||
! current_user_can( 'install_plugins' ) ||
|
||||
! isset( $_GET['nonce'] )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ) );
|
||||
|
||||
if ( ! wp_verify_nonce( $nonce, 'install-plugin' ) ) {
|
||||
wp_nonce_ays( 'install-plugin' );
|
||||
}
|
||||
|
||||
$plugins = sanitize_text_field( wp_unslash( $_GET['plugins'] ) );
|
||||
$plugin_action = sanitize_text_field( wp_unslash( $_GET['plugin_action'] ) );
|
||||
/* phpcs:enable WordPress.Security.NonceVerification.Recommended */
|
||||
|
|
|
@ -116,7 +116,7 @@ class WC_Tests_PaymentGatewaySuggestions_PaymentGatewaysController extends WC_RE
|
|||
public function test_connection_url() {
|
||||
$response = $this->get_mock_gateway_response();
|
||||
$this->assertEquals(
|
||||
'http://testconnection.com?return=' . wc_admin_url( '&task=payments&connection-return=mock-enhanced' ),
|
||||
'http://testconnection.com?return=' . wc_admin_url( '&task=payments&connection-return=mock-enhanced&_wpnonce=' . wp_create_nonce( 'connection-return' ) ),
|
||||
$response['connection_url']
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue