From fda5842e86330aff4b07a0ef1523c3233a3a73f9 Mon Sep 17 00:00:00 2001 From: Ian Dunn Date: Fri, 6 Dec 2019 08:03:54 -0800 Subject: [PATCH] Regularly store stats snapshots to track historical trends. Fixes #37 --- plugins/wporg-5ftf/includes/contributor.php | 41 ++++- plugins/wporg-5ftf/includes/stats.php | 189 ++++++++++++++++++++ plugins/wporg-5ftf/index.php | 1 + plugins/wporg-5ftf/views/list-stats.php | 54 ++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 plugins/wporg-5ftf/includes/stats.php create mode 100644 plugins/wporg-5ftf/views/list-stats.php diff --git a/plugins/wporg-5ftf/includes/contributor.php b/plugins/wporg-5ftf/includes/contributor.php index 83d1647..22c893d 100644 --- a/plugins/wporg-5ftf/includes/contributor.php +++ b/plugins/wporg-5ftf/includes/contributor.php @@ -281,7 +281,9 @@ function get_pledge_contributors_data( $pledge_id ) { } /** - * Get the user objects that correspond with pledge contributor posts. + * Get the user objects that correspond with contributor posts. + * + * @see `get_contributor_user_ids()` for a similar function. * * @param WP_Post[] $contributor_posts * @@ -293,6 +295,43 @@ function get_contributor_user_objects( array $contributor_posts ) { }, $contributor_posts ); } +/** + * Get user IDs for the given `CPT_ID` posts. + * + * This is similar to `get_contributor_user_objects()`, but returns more specific data, and is more performant + * with large data sets (e.g., with `get_snapshot_data()`) because there is 1 query instead of + * `count( $contributor_posts )`. + * + * @param WP_Post[] $contributor_posts + * + * @return array + */ +function get_contributor_user_ids( $contributor_posts ) { + global $wpdb; + + $usernames = wp_list_pluck( $contributor_posts, 'post_title' ); + + /* + * Generate placeholders dynamically, so that each username will be quoted individually rather than as a + * single string. + * + * @see https://developer.wordpress.org/reference/classes/wpdb/prepare/#comment-1557 + */ + $usernames_placeholders = implode( ', ', array_fill( 0, count( $usernames ), '%s' ) ); + + $query = " + SELECT id + FROM $wpdb->users + WHERE user_login IN( $usernames_placeholders ) + "; + + $user_ids = $wpdb->get_col( + $wpdb->prepare( $query, $usernames ) + ); + + return $user_ids; +} + /** * Only show the My Pledges menu to users who are logged in. * diff --git a/plugins/wporg-5ftf/includes/stats.php b/plugins/wporg-5ftf/includes/stats.php new file mode 100644 index 0000000..a92a40d --- /dev/null +++ b/plugins/wporg-5ftf/includes/stats.php @@ -0,0 +1,189 @@ + array( 'custom-fields' ), + 'public' => false, + 'show_in_rest' => true, + + // Only allow posts to be created programmatically. + 'capability_type' => CPT_ID, + 'capabilities' => array( + 'create_posts' => 'do_not_allow', + ), + ); + + register_post_type( CPT_ID, $args ); +} + +/** + * Schedule the cron job to record a stats snapshot. + */ +function schedule_cron_jobs() { + if ( wp_next_scheduled( PREFIX . '_record_snapshot' ) ) { + return; + } + + // Schedule a repeating "single" event to avoid having to create a custom schedule. + wp_schedule_single_event( + time() + ( 2 * WEEK_IN_SECONDS ), + PREFIX . '_record_snapshot' + ); +} + +/** + * Record a snapshot of the current stats, so we can track trends over time. + */ +function record_snapshot() { + $stats = get_snapshot_data(); + + $post_id = wp_insert_post( array( + 'post_type' => CPT_ID, + 'post_author' => 0, + 'post_title' => sprintf( '5ftF Stats Snapshot %s', date( 'Y-m-d' ) ), + 'post_status' => 'publish', + ) ); + + add_post_meta( $post_id, PREFIX . '_total_pledged_hours', $stats['confirmed_hours'] ); + add_post_meta( $post_id, PREFIX . '_total_pledged_contributors', $stats['confirmed_contributors'] ); + add_post_meta( $post_id, PREFIX . '_total_pledged_companies', $stats['confirmed_pledges'] ); + add_post_meta( $post_id, PREFIX . '_total_pledged_team_contributors', $stats['confirmed_team_contributors'] ); +} + +/** + * Calculate the stats for the current snapshot. + * + * @return array + */ +function get_snapshot_data() { + $snapshot_data = array( + 'confirmed_hours' => 0, + 'confirmed_team_contributors' => array(), + ); + + $confirmed_pledges = new WP_Query( array( + 'post_type' => Pledge\CPT_ID, + 'post_status' => 'publish', + 'numberposts' => 1, // We only need `found_posts`, not the posts themselves. + ) ); + + $snapshot_data['confirmed_pledges'] = $confirmed_pledges->found_posts; + + /* + * A potential future optimization would be make WP_Query only return the `post_title`. The `fields` parameter + * doesn't currently support `post_title`, but it may be possible with filters like `posts_fields` + * or `posts_fields_request`. That was premature at the time this code was written, though. + */ + $confirmed_contributors = get_posts( array( + 'post_type' => Contributor\CPT_ID, + 'post_status' => 'publish', + 'numberposts' => 2000, + ) ); + + /* + * Removing duplicates because a user sponsored by multiple companies will have multiple contributor posts, + * but their stats should only be counted once. + * + * A potential future optimization would be to remove duplicate `post_title` entries in the query itself, + * but `WP_Query` doesn't support `DISTINCT` directly, and it's premature at this point. It may be possible + * with the filters mentioned above. + */ + $confirmed_user_ids = array_unique( Contributor\get_contributor_user_ids( $confirmed_contributors ) ); + $snapshot_data['confirmed_contributors'] = count( $confirmed_user_ids ); + + $contributors_profile_data = XProfile\get_xprofile_contribution_data( $confirmed_user_ids ); + + foreach ( $contributors_profile_data as $profile_data ) { + switch ( (int) $profile_data['field_id'] ) { + case XProfile\FIELD_IDS['hours_per_week']: + $snapshot_data['confirmed_hours'] += absint( $profile_data['value'] ); + break; + + case XProfile\FIELD_IDS['team_names']: + /* + * BuddyPress validates the team name(s) the user provides before saving them in the database, so + * it should be safe to unserialize, and to assume that they're valid. + * + * The database stores team _names_ rather than _IDs_, though, so if a team is ever renamed, this + * data will be distorted. + */ + $associated_teams = maybe_unserialize( $profile_data['value'] ); + + foreach ( $associated_teams as $team ) { + if ( isset( $snapshot_data['confirmed_team_contributors'][ $team ] ) ) { + $snapshot_data['confirmed_team_contributors'][ $team ]++; + } else { + $snapshot_data['confirmed_team_contributors'][ $team ] = 1; + } + } + + break; + } + } + + return $snapshot_data; +} + +/** + * Render the shortcode to display stats. + * + * @return string + */ +function render_shortcode() { + $snapshots = get_posts( array( + 'post_type' => CPT_ID, + 'posts_per_page' => 500, + 'order' => 'ASC', + ) ); + + $stat_keys = array( + PREFIX . '_total_pledged_hours', + PREFIX . '_total_pledged_contributors', + PREFIX . '_total_pledged_companies', + PREFIX . '_total_pledged_team_contributors', + ); + + $stat_values = array(); + + // todo produce whatever data structure the visualization framework wants, and any a11y text fallback necessary. + // don't trust that visualization library will escape things properly, run numbers through absint(), team names through sanitize_text_field(), etc. + + foreach ( $snapshots as $snapshot ) { + $timestamp = strtotime( $snapshot->post_date ); + + foreach ( $stat_keys as $stat_key ) { + $stat_value = $snapshot->{ $stat_key }; + $stat_values[ $stat_key ][ $timestamp ] = $stat_value; + } + } + + ob_start(); + require FiveForTheFuture\get_views_path() . 'list-stats.php'; + return ob_get_clean(); +} diff --git a/plugins/wporg-5ftf/index.php b/plugins/wporg-5ftf/index.php index c165f3b..5744f10 100755 --- a/plugins/wporg-5ftf/index.php +++ b/plugins/wporg-5ftf/index.php @@ -34,6 +34,7 @@ function load() { require_once get_includes_path() . 'xprofile.php'; require_once get_includes_path() . 'endpoints.php'; require_once get_includes_path() . 'miscellaneous.php'; + require_once get_includes_path() . 'stats.php'; // The logger expects things like `$_POST` which aren't set during unit tests. if ( ! $running_unit_tests ) { diff --git a/plugins/wporg-5ftf/views/list-stats.php b/plugins/wporg-5ftf/views/list-stats.php new file mode 100644 index 0000000..c37e7a9 --- /dev/null +++ b/plugins/wporg-5ftf/views/list-stats.php @@ -0,0 +1,54 @@ + + +

+ This is just rough text-based output to check that it's working in a way that will be friendly for the vizualization that will be added in #38 (and a11y fallbacks, if any are needed). +

+ +

+ When that is implemented, the controller can add the data to a JSON array with `date` => `value` entries, or whatever the visualization library wants, rather than looping through it below. +

+ + + +