PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260917
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260917
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 releases
← All changes | src/includes/classes/users-list.inc.php +325 -22 260805260917 View file →
@@ -84,27 +84,104 @@
84 84 if(is_admin() && !empty($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'users.php')
85 85 if(isset ($query->query_vars) && !is_network_admin()) // NOT in Network admin panels.
86 86 if(is_array($qv = $query->query_vars) && ($s = trim($qv["search"], "* \t\n\r\0\x0B")) && ($s = "%".esc_sql(c_ws_plugin__s2member_utils_strings::like_escape($s))."%"))
87 87 {
88 - $query->query_fields = "SQL_CALC_FOUND_ROWS DISTINCT(`".$wpdb->users."`.`ID`)";
89 - $query->query_from = " FROM `".$wpdb->users."`, `".$wpdb->usermeta."`"; // Include meta table also.
90 - $query->query_where = " WHERE `".$wpdb->users."`.`ID` = `".$wpdb->usermeta."`.`user_id`"; // Join w/ meta table.
91 - $query->query_where .= " AND (".apply_filters("ws_plugin__s2member_before_users_list_search_where_or_before", "", get_defined_vars());
92 - $query->query_where .= " (`".$wpdb->users."`.`user_login` LIKE '".$s."' OR `".$wpdb->users."`.`user_nicename` LIKE '".$s."' OR `".$wpdb->users."`.`display_name` LIKE '".$s."' OR `".$wpdb->users."`.`user_email` LIKE '".$s."' OR `".$wpdb->users."`.`user_url` LIKE '".$s."')";
93 - $query->query_where .= " OR ((`".$wpdb->usermeta."`.`meta_key` = 'first_name' OR `".$wpdb->usermeta."`.`meta_key` = 'last_name') AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
94 - $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_subscr_id' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
95 - $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
96 - $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom_fields' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
97 - if(apply_filters("ws_plugin__s2member_users_list_search_admin_notes", FALSE, get_defined_vars())) // Off by default; this can get very slow on large sites.
98 - $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_notes' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
99 - $query->query_where .= apply_filters("ws_plugin__s2member_before_users_list_search_where_or_after", "", get_defined_vars()).")"; // Leaving room for additional searches here.
88 + //260915.0638 Searching Users used to JOIN every candidate user to all of their usermeta rows, then use DISTINCT to collapse the duplicate users. Membership sites can have many meta rows per user, so that greatly enlarges the intermediate result before MySQL can return a single page of Users. The normal path below keeps the users table as the main result and uses correlated EXISTS checks only for the specific meta keys s2Member searches. This produced the same IDs/counts while substantially reducing uncached search time on a large-user fixture.
89 + $_search_sql_customized = has_filter("ws_plugin__s2member_before_users_list_search_where_or_before") !== false
90 + || has_filter("ws_plugin__s2member_before_users_list_search_where_or_after") !== false
91 + || has_filter("ws_plugin__s2member_before_users_list_search_from") !== false
92 + || has_filter("ws_plugin__s2member_before_users_list_search_where") !== false;
100 93
101 - if(is_multisite()) // On a Multisite Network we need to make sure we're searching only users w/ capabilities on this blog.
102 - $query->query_where .= " AND `".$wpdb->users."`.`ID` IN(SELECT DISTINCT(`user_id`) FROM `".$wpdb->usermeta."` WHERE `meta_key` = '".$wpdb->prefix."capabilities')";
94 + if(!$_search_sql_customized)
95 + {
96 + //260915.0757 Keep usermeta out of the main FROM clause so one user remains one result row. EXISTS can stop looking once a relevant matching meta row is found, avoiding the legacy JOIN's row multiplication. Retain DISTINCT on the user ID for pagination/database-adapter compatibility; unlike the legacy query, it no longer has a large joined result to deduplicate.
97 + $_meta = "___s2_search_meta";
98 + $query->query_fields = "SQL_CALC_FOUND_ROWS DISTINCT(`".$wpdb->users."`.`ID`)";
99 + $query->query_from = " FROM `".$wpdb->users."`";
100 + //260915.0638 Preserve the legacy JOIN's subtle behavior: a users-table row with no usermeta at all was never returned, even when a normal user field matched.
101 + $query->query_where = " WHERE EXISTS (SELECT 1 FROM `".$wpdb->usermeta."` `___s2_any_meta` WHERE `___s2_any_meta`.`user_id` = `".$wpdb->users."`.`ID`)";
102 + $query->query_where .= " AND ((`".$wpdb->users."`.`user_login` LIKE '".$s."' OR `".$wpdb->users."`.`user_nicename` LIKE '".$s."' OR `".$wpdb->users."`.`display_name` LIKE '".$s."' OR `".$wpdb->users."`.`user_email` LIKE '".$s."' OR `".$wpdb->users."`.`user_url` LIKE '".$s."')";
103 + $query->query_where .= " OR EXISTS (SELECT 1 FROM `".$wpdb->usermeta."` `".$_meta."` WHERE `".$_meta."`.`user_id` = `".$wpdb->users."`.`ID` AND (";
104 + $query->query_where .= "((`".$_meta."`.`meta_key` = 'first_name' OR `".$_meta."`.`meta_key` = 'last_name') AND `".$_meta."`.`meta_value` LIKE '".$s."')";
105 + $query->query_where .= " OR (`".$_meta."`.`meta_key` = '".$wpdb->base_prefix."s2member_subscr_id' AND `".$_meta."`.`meta_value` LIKE '".$s."')";
106 + $query->query_where .= " OR (`".$_meta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom' AND `".$_meta."`.`meta_value` LIKE '".$s."')";
107 + $query->query_where .= " OR (`".$_meta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom_fields' AND `".$_meta."`.`meta_value` LIKE '".$s."')";
108 + if(apply_filters("ws_plugin__s2member_users_list_search_admin_notes", FALSE, get_defined_vars())) // Off by default; this can get very slow on large sites.
109 + $query->query_where .= " OR (`".$_meta."`.`meta_key` = '".$wpdb->base_prefix."s2member_notes' AND `".$_meta."`.`meta_value` LIKE '".$s."')";
110 + $query->query_where .= ")))";
103 111
104 - $query->query_from = apply_filters("ws_plugin__s2member_before_users_list_search_from", $query->query_from, get_defined_vars());
105 - $query->query_where = apply_filters("ws_plugin__s2member_before_users_list_search_where", $query->query_where, get_defined_vars());
112 + if(is_multisite()) // On a Multisite Network we need to make sure we're searching only users w/ capabilities on this blog.
113 + $query->query_where .= " AND `".$wpdb->users."`.`ID` IN(SELECT DISTINCT(`user_id`) FROM `".$wpdb->usermeta."` WHERE `meta_key` = '".$wpdb->prefix."capabilities')";
114 + }
115 + else
116 + {
117 + //260915.0638 Preserve the original JOIN-based query when an extension customizes s2Member's Users-search SQL. Those filters historically receive and may modify the legacy FROM/WHERE structure, so forcing the optimized query here could break integrations that depend on it.
118 + $query->query_fields = "SQL_CALC_FOUND_ROWS DISTINCT(`".$wpdb->users."`.`ID`)";
119 + $query->query_from = " FROM `".$wpdb->users."`, `".$wpdb->usermeta."`"; // Include meta table also.
120 + $query->query_where = " WHERE `".$wpdb->users."`.`ID` = `".$wpdb->usermeta."`.`user_id`"; // Join w/ meta table.
121 + $query->query_where .= " AND (".apply_filters("ws_plugin__s2member_before_users_list_search_where_or_before", "", get_defined_vars());
122 + $query->query_where .= " (`".$wpdb->users."`.`user_login` LIKE '".$s."' OR `".$wpdb->users."`.`user_nicename` LIKE '".$s."' OR `".$wpdb->users."`.`display_name` LIKE '".$s."' OR `".$wpdb->users."`.`user_email` LIKE '".$s."' OR `".$wpdb->users."`.`user_url` LIKE '".$s."')";
123 + $query->query_where .= " OR ((`".$wpdb->usermeta."`.`meta_key` = 'first_name' OR `".$wpdb->usermeta."`.`meta_key` = 'last_name') AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
124 + $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_subscr_id' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
125 + $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
126 + $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_custom_fields' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
127 + if(apply_filters("ws_plugin__s2member_users_list_search_admin_notes", FALSE, get_defined_vars())) // Off by default; this can get very slow on large sites.
128 + $query->query_where .= " OR (`".$wpdb->usermeta."`.`meta_key` = '".$wpdb->base_prefix."s2member_notes' AND `".$wpdb->usermeta."`.`meta_value` LIKE '".$s."')";
129 + $query->query_where .= apply_filters("ws_plugin__s2member_before_users_list_search_where_or_after", "", get_defined_vars()).")"; // Leaving room for additional searches here.
130 +
131 + if(is_multisite()) // On a Multisite Network we need to make sure we're searching only users w/ capabilities on this blog.
132 + $query->query_where .= " AND `".$wpdb->users."`.`ID` IN(SELECT DISTINCT(`user_id`) FROM `".$wpdb->usermeta."` WHERE `meta_key` = '".$wpdb->prefix."capabilities')";
133 +
134 + $query->query_from = apply_filters("ws_plugin__s2member_before_users_list_search_from", $query->query_from, get_defined_vars());
135 + $query->query_where = apply_filters("ws_plugin__s2member_before_users_list_search_where", $query->query_where, get_defined_vars());
136 + }
137 + unset($_meta, $_search_sql_customized);
106 138 }
139 +
140 + if(is_admin() && !is_network_admin() && !empty($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'users.php' && !empty($_GET['s2member_view']) && isset($query->query_vars))
141 + {
142 + $is_eot_current_view = $_GET['s2member_view'] === 'eot_current';
143 + $is_eot_previous_view = $_GET['s2member_view'] === 'eot_previous';
144 +
145 + if($is_eot_current_view || $is_eot_previous_view)
146 + {
147 + $current_eot_key = esc_sql($wpdb->prefix.'s2member_auto_eot_time');
148 + $last_eot_key = esc_sql($wpdb->prefix.'s2member_last_auto_eot_time');
149 + $processed_eot_key = esc_sql($wpdb->prefix.'s2member_last_auto_eot_processed_time');
150 +
151 + if($is_eot_current_view)
152 + {
153 + //260828.0231 Current End-of-Term is the actionable queue: only users with a current EOT belong here, so native ascending EOT sorting never has blank rows to place first.
154 + $query->query_where .= " AND EXISTS (SELECT 1 FROM `".$wpdb->usermeta."` `___s2_eot_current` WHERE `___s2_eot_current`.`user_id` = `".$wpdb->users."`.`ID` AND `___s2_eot_current`.`meta_key` = '".$current_eot_key."' AND CAST(`___s2_eot_current`.`meta_value` AS UNSIGNED) > 0)";
155 +
156 + if(empty($_REQUEST['orderby']) && empty($_REQUEST['s']))
157 + {
158 + $query->query_from .= " LEFT JOIN `".$wpdb->usermeta."` `___s2_eot_order_current` ON (`".$wpdb->users."`.`ID` = `___s2_eot_order_current`.`user_id` AND `___s2_eot_order_current`.`meta_key` = '".$current_eot_key."')";
159 + $query->query_orderby = "ORDER BY CAST(`___s2_eot_order_current`.`meta_value` AS UNSIGNED) ASC";
160 + }
161 + }
162 + else
163 + {
164 + //260828.0231 Previous End-of-Term includes either archived EOT or demotion history so partial/legacy records remain discoverable without mixing them into the current queue.
165 + $query->query_where .= " AND (EXISTS (SELECT 1 FROM `".$wpdb->usermeta."` `___s2_eot_last` WHERE `___s2_eot_last`.`user_id` = `".$wpdb->users."`.`ID` AND `___s2_eot_last`.`meta_key` = '".$last_eot_key."' AND CAST(`___s2_eot_last`.`meta_value` AS UNSIGNED) > 0) OR EXISTS (SELECT 1 FROM `".$wpdb->usermeta."` `___s2_eot_processed` WHERE `___s2_eot_processed`.`user_id` = `".$wpdb->users."`.`ID` AND `___s2_eot_processed`.`meta_key` = '".$processed_eot_key."' AND CAST(`___s2_eot_processed`.`meta_value` AS UNSIGNED) > 0))";
166 +
167 + if(empty($_REQUEST['orderby']) && empty($_REQUEST['s']))
168 + {
169 + //260828.0358 Previous End-of-Term is a processing-history list, so show the most recently processed EOT actions first; records without a recoverable demotion time naturally remain after dated records.
170 + $query->query_from .= " LEFT JOIN `".$wpdb->usermeta."` `___s2_eot_order_processed` ON (`".$wpdb->users."`.`ID` = `___s2_eot_order_processed`.`user_id` AND `___s2_eot_order_processed`.`meta_key` = '".$processed_eot_key."')";
171 + $query->query_orderby = "ORDER BY CAST(`___s2_eot_order_processed`.`meta_value` AS UNSIGNED) DESC";
172 + }
173 + }
174 + }
175 + }
176 +
177 + if(is_admin() && !is_network_admin() && !empty($GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'users.php' && !empty($_GET['role']) && $_GET['role'] === 's2member_pending_deletion' && isset($query->query_vars) && empty($_REQUEST['orderby']) && empty($_REQUEST['s']))
178 + {
179 + //260828.0317 Pending Deletion is an action queue, so show the accounts waiting longest first by their actual EOT demotion time instead of WordPress's Username default.
180 + $processed_eot_key = esc_sql($wpdb->prefix.'s2member_last_auto_eot_processed_time');
181 + $query->query_from .= " LEFT JOIN `".$wpdb->usermeta."` `___s2_pending_order_processed` ON (`".$wpdb->users."`.`ID` = `___s2_pending_order_processed`.`user_id` AND `___s2_pending_order_processed`.`meta_key` = '".$processed_eot_key."')";
182 + $query->query_orderby = "ORDER BY CAST(`___s2_pending_order_processed`.`meta_value` AS UNSIGNED) ASC";
183 + }
107 184 foreach(array_keys(get_defined_vars()) as $__v) $__refs[$__v] =& $$__v;
108 185 do_action("ws_plugin__s2member_after_users_list_search", get_defined_vars());
109 186 unset($__refs, $__v);
110 187 }
@@ -137,8 +214,11 @@
137 214 if(!is_multisite() || !c_ws_plugin__s2member_utils_conds::is_multisite_farm() || is_main_site())
138 215 $cols["s2member_ccaps"] = "Custom Capabilities";
139 216
140 217 $cols["s2member_auto_eot_time"] = "EOT Time";
218 + //260822.1509 Keep the triggering EOT and its actual processing time separate; both remain optional history columns on the normal Users screen.
219 + $cols["s2member_last_auto_eot_time"] = "Last EOT";
220 + $cols["s2member_last_auto_eot_processed_time"] = "EOT Demotion";
141 221
142 222 if($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["custom_reg_fields"])
143 223 foreach(json_decode($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["custom_reg_fields"], TRUE) as $field)
144 224 {
@@ -159,8 +239,210 @@
159 239 return apply_filters("ws_plugin__s2member_users_list_cols", $cols, get_defined_vars());
160 240 }
161 241
162 242 /**
243 + * Re-adds required EOT columns late on s2Member's review views after column-management plugins have filtered the Users table.
244 + *
245 + * @package s2Member\Users_List
246 + * @since 260826.0542
247 + *
248 + * @attaches-to ``add_filter("manage_users_columns");``
249 + *
250 + * @param array $cols User list columns after other plugins have filtered them.
251 + *
252 + * @return array User list columns with the required EOT review columns restored.
253 + */
254 + public static function users_list_required_eot_cols($cols = array())
255 + {
256 + if(!is_admin() || empty($GLOBALS['pagenow']) || $GLOBALS['pagenow'] !== 'users.php')
257 + return $cols;
258 +
259 + $is_eot_current_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_current';
260 + $is_eot_previous_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_previous';
261 + $is_pending_deletion_view = !empty($_GET['role']) && $_GET['role'] === 's2member_pending_deletion';
262 +
263 + //260828.0231 Restore only the columns required by each s2Member review view after Users-table customization plugins apply saved layouts.
264 + if($is_eot_current_view || $is_pending_deletion_view)
265 + $cols['s2member_auto_eot_time'] = 'EOT Time';
266 +
267 + if($is_eot_previous_view || $is_pending_deletion_view)
268 + {
269 + $cols['s2member_last_auto_eot_time'] = 'Last EOT';
270 + $cols['s2member_last_auto_eot_processed_time'] = 'EOT Demotion';
271 + }
272 + return $cols;
273 + }
274 +
275 + /**
276 + * Hides optional s2Member history columns by default on the Users screen.
277 + *
278 + * @package s2Member\Users_List
279 + * @since 260822.1509
280 + *
281 + * @attaches-to ``add_filter("default_hidden_columns");``
282 + *
283 + * @param array $hidden Default hidden column IDs.
284 + * @param WP_Screen $screen Current screen object.
285 + *
286 + * @return array Filtered hidden column IDs.
287 + */
288 + public static function users_list_default_hidden_cols($hidden = array(), $screen = NULL)
289 + {
290 + if(is_object($screen) && !empty($screen->id) && $screen->id === 'users')
291 + {
292 + //260822.1509 Preserve the normal Users table's compact default while leaving both EOT history columns available through Screen Options.
293 + $hidden[] = 's2member_last_auto_eot_time';
294 + $hidden[] = 's2member_last_auto_eot_processed_time';
295 + $hidden = array_values(array_unique($hidden));
296 + }
297 + return $hidden;
298 + }
299 +
300 + /**
301 + * 260828.0231 Exposes the required End-of-Term columns in s2Member's Users review views.
302 + *
303 + * @package s2Member\Users_List
304 + * @since 260822.1519
305 + *
306 + * @attaches-to ``add_filter("hidden_columns");``
307 + *
308 + * @param array $hidden Hidden column IDs.
309 + * @param WP_Screen $screen Current screen object.
310 + *
311 + * @return array Filtered hidden column IDs.
312 + */
313 + public static function users_list_hidden_cols($hidden = array(), $screen = NULL)
314 + {
315 + if(is_object($screen) && !empty($screen->id) && $screen->id === 'users')
316 + {
317 + $is_eot_current_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_current';
318 + $is_eot_previous_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_previous';
319 + $is_pending_deletion_view = !empty($_GET['role']) && $_GET['role'] === 's2member_pending_deletion';
320 +
321 + //260828.0231 Each review view forces only its primary timestamps visible; other Users columns continue to follow the administrator's Screen Options.
322 + if($is_eot_current_view)
323 + $hidden = array_diff($hidden, array('s2member_auto_eot_time'));
324 + else if($is_eot_previous_view)
325 + $hidden = array_diff($hidden, array('s2member_last_auto_eot_time', 's2member_last_auto_eot_processed_time'));
326 + else if($is_pending_deletion_view)
327 + $hidden = array_diff($hidden, array('s2member_auto_eot_time', 's2member_last_auto_eot_time', 's2member_last_auto_eot_processed_time'));
328 +
329 + $hidden = array_values($hidden);
330 + }
331 + return $hidden;
332 + }
333 +
334 + /**
335 + * 260828.0231 Adds the Current and Previous End-of-Term reports to WordPress' native Users views.
336 + *
337 + * @package s2Member\Users_List
338 + * @since 260822.1519
339 + *
340 + * @attaches-to ``add_filter("views_users");``
341 + *
342 + * @param array $views Native Users view links.
343 + *
344 + * @return array Filtered Users view links.
345 + */
346 + public static function users_list_views($views = array())
347 + {
348 + global $wpdb;
349 +
350 + if(!is_admin() || is_network_admin() || empty($GLOBALS['pagenow']) || $GLOBALS['pagenow'] !== 'users.php')
351 + return $views;
352 +
353 + $is_eot_current_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_current';
354 + $is_eot_previous_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_previous';
355 + $is_pending_deletion_view = !empty($_GET['role']) && $_GET['role'] === 's2member_pending_deletion';
356 + $current_eot_key = $wpdb->prefix.'s2member_auto_eot_time';
357 + $last_eot_key = $wpdb->prefix.'s2member_last_auto_eot_time';
358 + $processed_eot_key = $wpdb->prefix.'s2member_last_auto_eot_processed_time';
359 + $has_current_eot_users = $is_eot_current_view || (bool)$wpdb->get_var($wpdb->prepare("SELECT 1 FROM `".$wpdb->usermeta."` WHERE `meta_key` = %s AND CAST(`meta_value` AS UNSIGNED) > 0 LIMIT 1", $current_eot_key));
360 + $has_previous_eot_users = $is_eot_previous_view || (bool)$wpdb->get_var($wpdb->prepare("SELECT 1 FROM `".$wpdb->usermeta."` WHERE `meta_key` IN (%s, %s) AND CAST(`meta_value` AS UNSIGNED) > 0 LIMIT 1", $last_eot_key, $processed_eot_key));
361 +
362 + if(($is_eot_current_view || $is_eot_previous_view) && isset($views['all']))
363 + $views['all'] = str_replace(' class="current" aria-current="page"', '', $views['all']);
364 +
365 + //260828.0231 Keep each EOT report out of unused Users screens, while direct report URLs remain self-identifying even when the selected report is empty.
366 + if($has_current_eot_users)
367 + {
368 + $url = add_query_arg('s2member_view', 'eot_current', admin_url('users.php'));
369 + $views['s2member_eot_current'] = '<a href="'.esc_url($url).'"'.($is_eot_current_view ? ' class="current" aria-current="page"' : '').'>End-of-Term Current</a>';
370 + }
371 +
372 + if($has_previous_eot_users)
373 + {
374 + $url = add_query_arg('s2member_view', 'eot_previous', admin_url('users.php'));
375 + $views['s2member_eot_previous'] = '<a href="'.esc_url($url).'"'.($is_eot_previous_view ? ' class="current" aria-current="page"' : '').'>End-of-Term Previous</a>';
376 + }
377 +
378 + $pending_deletion_view = isset($views['s2member_pending_deletion']) ? $views['s2member_pending_deletion'] : '';
379 + unset($views['s2member_pending_deletion']);
380 + $delete_behavior = isset($GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior']) && $GLOBALS['WS_PLUGIN__']['s2member']['o']['membership_eot_behavior'] === 'delete';
381 + //260828.0337 WordPress's native role view already proves pending users exist; preserve it before any fallback query can inherit an active s2Member EOT report filter.
382 + $has_pending_deletion_users = $is_pending_deletion_view || (bool)$pending_deletion_view || (!$delete_behavior && (bool)get_users(array('role' => 's2member_pending_deletion', 'number' => 1, 'fields' => 'ID')));
383 +
384 + //260823.0130 Pending Deletion is useful navigation while "Delete" is configured or review work exists; hide the otherwise-empty role link without unregistering the role or affecting direct access.
385 + if($delete_behavior || $has_pending_deletion_users)
386 + {
387 + if(!$pending_deletion_view)
388 + {
389 + $url = add_query_arg('role', 's2member_pending_deletion', admin_url('users.php'));
390 + $pending_deletion_view = '<a href="'.esc_url($url).'"'.($is_pending_deletion_view ? ' class="current" aria-current="page"' : '').'>Pending Deletion <span class="count">(0)</span></a>';
391 + }
392 + $views['s2member_pending_deletion'] = $pending_deletion_view;
393 + }
394 +
395 + return $views;
396 + }
397 +
398 + /**
399 + * 260828.0231 Explains the native Current and Previous End-of-Term Users views.
400 + *
401 + * @package s2Member\Users_List
402 + * @since 260823.0302
403 + *
404 + * @attaches-to ``add_action("admin_notices");``
405 + *
406 + * @return null
407 + */
408 + public static function users_list_eot_notice()
409 + {
410 + if(!is_admin() || is_network_admin() || empty($GLOBALS['pagenow']) || $GLOBALS['pagenow'] !== 'users.php' || empty($_GET['s2member_view']))
411 + return;
412 +
413 + if($_GET['s2member_view'] === 'eot_current')
414 + {
415 + //260828.0358 Describe this as a user-facing list rather than WordPress's internal "view" terminology; oldest/overdue current EOTs remain first.
416 + c_ws_plugin__s2member_admin_notices::display_admin_notice('This list includes users with a current End-of-Term time, ordered by the earliest EOT Time first.');
417 + }
418 + else if($_GET['s2member_view'] === 'eot_previous')
419 + {
420 + //260828.0358 Previous EOTs are processing-history records, so explain both the historical scope and the EOT Demotion ordering directly.
421 + c_ws_plugin__s2member_admin_notices::display_admin_notice('This list includes users who had an End-of-Term time before, ordered by the most recent EOT Demotion first.');
422 + }
423 + }
424 +
425 + /**
426 + * Explains the native Pending Deletion role view.
427 + *
428 + * @package s2Member\Users_List
429 + * @since 260822.1519
430 + *
431 + * @attaches-to ``add_action("admin_notices");``
432 + *
433 + * @return null
434 + */
435 + public static function users_list_pending_deletion_notice()
436 + {
437 + if(!is_admin() || is_network_admin() || empty($GLOBALS['pagenow']) || $GLOBALS['pagenow'] !== 'users.php' || empty($_GET['role']) || $_GET['role'] !== 's2member_pending_deletion')
438 + return;
439 +
440 + //260828.0358 Describe Pending Deletion as a list rather than a WordPress "view," while explaining why these accounts remain available for administrator review.
441 + c_ws_plugin__s2member_admin_notices::display_admin_notice('This list includes accounts preserved in <strong>Pending Deletion</strong> after their membership access was removed at End-of-Term. Use WordPress\'s normal bulk Delete action when you are ready to delete them.');
442 + }
443 +
444 + /**
163 445 * Displays column data in the row of details.
164 446 *
165 447 * @package s2Member\Users_List
166 448 * @since 3.5
@@ -212,10 +494,16 @@
212 494
213 495 $val = (!empty($ccaps)) ? implode("<br />", $ccaps) : "—";
214 496 }
215 497 else if($col === "s2member_auto_eot_time")
216 - $val = ($v = get_user_option("s2member_auto_eot_time", $user_id)) ? date("D M jS, Y", (integer)$v)."<br /><small>@ precisely ".date("g:i a", (integer)$v)."</small>" : "—";
498 + $val = ($v = get_user_option("s2member_auto_eot_time", $user_id)) ? date("D M jS, Y", (int)$v)."<br /><small>@ precisely ".date("g:i a", (int)$v)."</small>" : "—";
217 499
500 + else if($col === "s2member_last_auto_eot_time" || $col === "s2member_last_auto_eot_processed_time")
501 + {
502 + //260822.1509 Match the existing EOT Time presentation so current, triggering, and processed timestamps compare directly in the same Users table.
503 + $val = ($v = get_user_option($col, $user_id)) ? date("D M jS, Y", (int)$v)."<br /><small>@ precisely ".date("g:i a", (int)$v)."</small>" : "—";
504 + }
505 +
218 506 else if(preg_match("/^s2member_custom_field_/", $col))
219 507 {
220 508 if(!$last_fields_id || $last_fields_id !== $user_id)
221 509 $fields = get_user_option("s2member_custom_fields", $user_id);
@@ -266,14 +554,27 @@
266 554 {
267 555 if(!empty($_REQUEST['s']))
268 556 return $columns;
269 557
270 - $columns['s2member_registration_time'] = 's2member_registration_time';
271 - $columns['s2member_subscr_id'] = 's2member_subscr_id';
272 - $columns['s2member_auto_eot_time'] = 's2member_auto_eot_time';
273 - $columns['s2member_login_counter'] = 's2member_login_counter';
274 - $columns['s2member_last_login_time'] = 's2member_last_login_time';
558 + $is_eot_current_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_current';
559 + $is_eot_previous_view = !empty($_GET['s2member_view']) && $_GET['s2member_view'] === 'eot_previous';
560 + $is_pending_deletion_view = !empty($_GET['role']) && $_GET['role'] === 's2member_pending_deletion';
561 + if($is_eot_current_view || $is_eot_previous_view || $is_pending_deletion_view)
562 + {
563 + //260828.0317 WordPress normally marks Username as initially sorted; each EOT review view instead exposes its own default timestamp and direction in the native header state.
564 + foreach($columns as $_column_key => $_column_data)
565 + if(is_array($_column_data) && isset($_column_data[4]))
566 + $columns[$_column_key][4] = false;
567 + }
275 568
569 + $columns['s2member_registration_time'] = 's2member_registration_time';
570 + $columns['s2member_subscr_id'] = 's2member_subscr_id';
571 + $columns['s2member_auto_eot_time'] = $is_eot_current_view ? array('s2member_auto_eot_time', false, 'EOT Time', 'Table ordered by current End-of-Term time.', 'asc') : 's2member_auto_eot_time';
572 + $columns['s2member_last_auto_eot_time'] = 's2member_last_auto_eot_time';
573 + $columns['s2member_last_auto_eot_processed_time'] = $is_eot_previous_view ? array('s2member_last_auto_eot_processed_time', false, 'EOT Demotion', 'Table ordered by End-of-Term demotion time.', 'desc') : ($is_pending_deletion_view ? array('s2member_last_auto_eot_processed_time', false, 'EOT Demotion', 'Table ordered by End-of-Term demotion time.', 'asc') : 's2member_last_auto_eot_processed_time');
574 + $columns['s2member_login_counter'] = 's2member_login_counter';
575 + $columns['s2member_last_login_time'] = 's2member_last_login_time';
576 +
276 577 return $columns;
277 578 }
278 579
279 580 /**
@@ -310,8 +611,10 @@
310 611 $query->query_orderby = "ORDER BY `___m`.`meta_value` ".$vars['order'];
311 612 break;
312 613
313 614 case 's2member_auto_eot_time':
615 + case 's2member_last_auto_eot_time':
616 + case 's2member_last_auto_eot_processed_time':
314 617 case 's2member_login_counter':
315 618 case 's2member_last_login_time':
316 619 $query->query_from .= " LEFT JOIN `".$wpdb->usermeta."` `___m` ON (`".$wpdb->users."`.`ID` = `___m`.`user_id` AND `___m`.`meta_key` = '".esc_sql($wpdb->prefix.$vars['orderby'])."')";
317 620 $query->query_orderby = "ORDER BY CAST(`___m`.`meta_value` AS UNSIGNED) ".$vars['order'];