Extra User Data

Header image for Extra User Data

Display more useful information on what your users have created.

14 minute reading time of 2972 words (inc code) Code available at codeberg.org and the last commit was Plugin available at WordPress

Intro

WP has all these features to make your own custom post types, but yet the Users index in the admin area will still only show you the number of standard Posts that someone has authored. If you’re managing a larger site, with multiple post types and different users, then I want to be able to see a bit more information than this default.

add_action('user_register', array($this, 'register_ip'));
add_action('wp_login', array($this, 'last_login'));

...

public function register_ip($user_id) {
    update_user_meta($user_id, 'registration_ip', $_SERVER['REMOTE_ADDR']);
}

public function last_login($login) {
    $user = get_user_by('login', $login);
    $count = get_user_meta($user->ID, 'login_count', true) OR 0; $count++;

    update_user_meta($user->ID, 'last_login', current_time('mysql'));
    update_user_meta($user->ID, 'last_login_ip', $_SERVER['REMOTE_ADDR']);
    update_user_meta($user->ID, 'login_count', $count);
}

And while we primarily want to see more on who’s written what, it can also be useful to see where and when as well.

if (is_admin()) {
    // display our columns in the index
    add_filter('manage_users_columns', array($this, 'manage_users_columns'));
    add_filter('manage_users_custom_column', array($this, 'manage_users_custom_column'), 10, 3);
    add_filter('manage_users_sortable_columns', array($this, 'manage_users_custom_sortable_columns'));
    add_filter('pre_user_query', array($this, 'manage_users_orderby'));
    // our css
    add_action('admin_footer', array($this, 'resize_users_columns'));

    // not strictly necessary, but just to show you're viewing a specific user's stuff
    global $pagenow; if (
        (isset($_REQUEST['author']) AND 'edit.php' == $pagenow) OR 
        (isset($_REQUEST['user_id']) AND 'edit-comments.php' == $pagenow)
    ) {
        add_action('admin_head', array($this, 'change_admin_page_title_start'));
        add_action('admin_footer', array($this, 'change_admin_page_title_end'));
    }
}

In order to display the extra detail, there are multiple filters that WP needs to have updated with our new requests.

public function manage_users_columns($columns) {
    $new = array(
        'comments'        => '<span class="dashicons dashicons-admin-comments" title="'.__('Comments').'">
            \ </span><span class="screen-reader-text">'.__('Comments').'</span>',
        'custom_posts'    => __('All Assets'),
        'last_login'      => __('Last Login'),
        'registered_date' => __('Registered'),
        'total_logins'    => __('Total Logins'),
        'user_id'         => __('ID'),
    );
    if (is_array($columns)) {
        $columns = array_merge($columns, $new);
    } else {
        $columns = $new;
    }
    return $columns;
}

The columns for each bit of the new information.

public function manage_users_custom_column($output, $column_name, $user_id) {
    switch ($column_name) {
        case 'comments':
            $counts = $this->get_author_comment_counts($user_id);
            $output = array();
            foreach ($counts as $type => $total) {
                if (0 < $total) {
                    $class = $type;
                    if ('0' == $type) $class = 'pending'; elseif ('1' == $type) $class = 'approved';
                    $text = sprintf('%d %s %s', $total, $class, _n('comment', 'comments', $total));
                    $output[]= '<span class="post-com-count post-com-count-'.$class.'" title="'.$text.'">'
                             . '<span class="comment-count-'.$class.'" aria-hidden="true">'.$total.'</span>'
                             . '<span class="screen-reader-text">'.$text.'</span>'
                             . '</span>';
                }
            }
            $output = implode('', $output); $output = ('' != $output) ? '<a href="'.admin_url().
                \ 'edit-comments.php?user_id='.$user_id.'">'.$output.'</a>' : '-';
            break;

        case 'custom_posts':
            $counts = $this->get_author_post_type_counts($user_id);
            $output = array();
            if (isset($counts[$user_id]) AND is_array($counts[$user_id]))
                foreach ($counts[$user_id] as $count) {
                    $link = admin_url().'edit.php?post_type='.$count['type'].'&author='.$user_id;
                    $output[] = "<li><a href={$link}>{$count['label']}: <b>{$count['count']}</b></a></li>";
                }
            $output = implode("\n", $output);
            if (empty($output)) $output = '<li>'.__('None').'</li>';
            $output = "<ul style='margin:0;'>\n{$output}\n</ul>";
            break;
        
        case 'last_login':
            $user = get_userdata($user_id); $output = '-';
            if ($user->last_login) {
                $output = date(get_option('date_format'), strtotime($user->last_login))
                        . '<br><small>@ '
                        . date(get_option('time_format'), strtotime($user->last_login))
                        . '</small>';
                if (isset($user->last_login_ip))
                    $output.= '<br><small>IP: '.$user->last_login_ip.'</small>';

                // wrap a total count around this column
                $output = '<span title="'.sprintf(__('%d total logins'), 
                            \ $user->login_count).'">'.$output.'</span>';
            }
            break;

        case 'registered_date':
            $user = get_userdata($user_id); $output = '-';
            if ($user->user_registered) {
                $output = date(get_option('date_format'), strtotime($user->user_registered))
                        . '<br><small>@ '
                        . date(get_option('time_format'), strtotime($user->user_registered))
                        . '</small>';
                if (isset($user->registration_ip))
                    $output.= '<br><small>IP: '.$user->registration_ip.'</small>';
            }
            break;

        case 'total_logins':
            $user = get_userdata($user_id);
            $output = ($user->login_count) ? $user->login_count : '0';
            break;

        case 'user_id':
            $output = $user_id;
            break;
    }
    return $output;
}

And when WP loops through each User in the index, this function is called for each column. Most of the columns can retrieve their data from standard WP functions, but for the Comments and Assets columns, I needed to write a couple more. Note that when the Assets column is shown, each post type is linked to its respective Edit screen, so that you can quickly see all those particular posts by that user.

// used for the Comments column
private function get_author_comment_counts($user_id = 0) {
    // how WP stores comment status in the db table
    $counts = array(
        '1' => 0, '0' => 0, 'spam' => 0, 'trash' => 0,
    );
    global $wpdb; $data = $wpdb->get_results($wpdb->prepare("
        SELECT   comment_approved, COUNT(*) AS total
        FROM     {$wpdb->prefix}comments c
        WHERE    user_id = %d
        GROUP BY comment_approved
    ", $user_id));

    if (is_array($data)) {
        foreach ($data as $row) {
            $counts[$row->comment_approved] = $row->total;
        }
    }
    return $counts;
}

// used for the Assets (custom_posts) column
private function get_author_post_type_counts($user_id = 0) {
    $counts = array();
    global $wpdb, $wp_post_types;
    
    $posts = $wpdb->get_results($wpdb->prepare("
        SELECT  p.post_author, p.post_type, COUNT(*) AS post_count
        FROM    {$wpdb->prefix}posts p
        WHERE   1=1
                AND p.post_type NOT IN ('revision', 'nav_menu_item')
                AND p.post_status IN ('publish', 'pending', 'draft')
                AND p.post_author = %d
        GROUP BY p.post_type
        ", $user_id, $user_id));
        
    foreach ($posts as $post) {
        if (isset($wp_post_types[$post->post_type])) {
            $post_type_object = $wp_post_types[$post->post_type];

            // custom post types can be set up in a few ways
            if (! empty($post_type_object->label))
                $label = $post_type_object->label;
            elseif (! empty($post_type_object->labels->name))
                $label = $post_type_object->labels->name;
            else
                $label = ucfirst(str_replace(array('-','_'), ' ', $post->post_type));
            
            // init each authors post type count nicely
            if (! isset($counts[$post->post_author]))
                $counts[$post->post_author] = array();

            // before recording it
            $counts[$post->post_author][] = array('label' => $label, 
                \ 'count' => $post->post_count, 'type' => $post->post_type);
        }
    }
    return $counts;
}

The respective functions to retrieve the data for the Comments and Assets columns

// let WP know that our new columns can be sorted
public function manage_users_custom_sortable_columns($columns) {
    $new = array(
        'comments'        => 'comments',
        'custom_posts'    => 'custom_posts',
        'last_login'      => 'last_login',
        'registered_date' => 'registered_date',
        'total_logins'    => 'total_logins',
        'user_id'         => 'ID',
    );
    if (is_array($columns)) {
        $columns = array_merge($columns, $new);
    } else {
        $columns = $new;
    }
    return $columns;
}

Having all this extra info is all well and good, but it’s even better if WP knows that it can be sorted.

public function manage_users_orderby($query) {
    $orderby = (isset($_REQUEST['orderby']) AND in_array($_REQUEST['orderby'], array('comments',
        \ 'custom_posts', 'last_login', 'registered_date', 'total_logins'))) ? $_REQUEST['orderby'] : '';
    $order = (isset($_REQUEST['order']) AND in_array($_REQUEST['order'], array('asc', 'desc'))) ?
        \ strtoupper($_REQUEST['order']) : '';

    global $wpdb;
    switch ($orderby) {
        case 'comments':
            $query->query_from = "FROM {$wpdb->users} LEFT OUTER JOIN {$wpdb->comments} c ON {$wpdb->users}.ID = c.user_id";
            $query->query_orderby = "GROUP BY {$wpdb->users}.ID ORDER BY COUNT(*) {$order}";
            break;

        case 'custom_posts':
            $query->query_from = "FROM {$wpdb->users} LEFT OUTER JOIN {$wpdb->posts} p ON {$wpdb->users}.ID = p.post_author";
            $query->query_where = "WHERE p.post_type NOT IN ('revision', 'nav_menu_item') AND p.post_status IN ('publish', 'pending', 'draft')";
            $query->query_orderby = "GROUP BY {$wpdb->users}.ID ORDER BY COUNT(*) {$order}";
            break;

        case 'last_login':
            $query->query_from = "FROM {$wpdb->users} LEFT OUTER JOIN {$wpdb->usermeta} um ON {$wpdb->users}.ID = um.user_id AND um.meta_key = 'last_login'";
            $query->query_orderby = "ORDER BY um.meta_value {$order}";
            break;

        case 'registered_date':
            $query->query_orderby = "ORDER BY user_registered {$order}";
            break;

        case 'total_logins':
            $query->query_from = "FROM {$wpdb->users} LEFT OUTER JOIN {$wpdb->usermeta} um ON {$wpdb->users}.ID = um.user_id AND um.meta_key = 'login_count'";
            $query->query_orderby = "ORDER BY um.meta_value {$order}";
            break;
    }
    
    return $query;    
}

And this is how WP knows how to sort the new columns - by hooking into the WP query before it gets run (pre_user_query), I can overwrite the various SQL clauses depending on the column. Again, the Comments and Assets need slightly more work than the rest (and sorting by ID needs none at all).

There may be an occasion when the custom_posts WHERE clause should include or exclude alternate post_types and statuses - if that happens, I’d add a settings page and probably roll my old Alter Users Screen plugin into this one.

// can't use a nice filter as WP doesn't use one there, so have to do it this way instead
public function change_admin_page_title_start() {
    ob_start(array($this, 'change_admin_page_title'));
}
public function change_admin_page_title($html) {
    $user_id = 0;
    if (isset($_REQUEST['user_id'])) $user_id = (int) $_REQUEST['user_id'];
    elseif (isset($_REQUEST['author'])) $user_id = (int) $_REQUEST['author'];
    $user = get_userdata($user_id);

    if ($user !== false) {
        $name = $user->first_name.' '.$user->last_name; if (trim($name) == '') $name = $user->user_login;

        global $pagenow;
        if ('edit-comments.php' == $pagenow) {
            $new_title = '<h1>'.sprintf(__('Comments by %s'), $name).'</h1>';
            $html = preg_replace('/<h1( class="[^"]+")?>\s*'.__('Comments').'\s*<\/h1>/', $new_title, $html);

        } elseif ('edit.php' == $pagenow) {
            global $post_type_object;
            $new_title = '<h1>'.sprintf(__('%s by %s'), esc_html($post_type_object->labels->name), $name);
            $html = preg_replace('/<h1( class="[^"]+")?>\s*'.esc_html($post_type_object->
                \ labels->name).'\s*<\/h1>/', $new_title, $html);
        }
    }

    return $html;
}
public function change_admin_page_title_end() {
    ob_end_flush();
}

Finally, because the Assets column links each post type plus author to the Edit screen, it would be nice to reflect that in the subsequent page title - unfortunately, there isn’t a prebuilt WP filter for that, so I have to buffer the output into a string and regex a replacement title instead.


Reply via email