The International Society of Biocuration (ISB) partners with the journal Database to get discounts for its members when they publish there. This means the ISB’s executive committee needs to send a member list to the journal’s editor. Historically, this has been done manually by exporting the list from the membership management plugin in the ISB Wordpress blog once per month and emailing it to th This post is about my journey trying to automate it

1. There must be an API for this

Wordpress has a programmatic API, and specifically an endpoint to list users.

After logging into the ISB’s Wordpress site, I was able to list users by navigating to the endpoint in browser https://biocuration.org/wp-json/wp/v2/users. Note: this page won’t work for you unless you’re on the EC and have admin powers. I wanted to replicate accessing this page through a Python script, so I was suggested by the official Wordpress documentation to create an application password and then use simple HTTP authentication. The docs said try this, after replacing the username with the Wordpress account (for me, that’s cthoyt) and the application password, which is a string with the form of XXXX XXXX XXXX XXXX XXXX XXXX. The documentation suggested doing the following curl command, which works:

curl --user "USERNAME:PASSWORD" https://biocuration.org/wp-json/wp/v2/users

If you want to translate this to Python, it looks like this.

import pystow, base64, requests

username = pystow.get_config("isb", "wordpress_username", raise_on_missing=True)
password = pystow.get_config("isb", "wordpress_password", raise_on_missing=True)

token = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")

res = requests.get(
    'https://www.biocuration.org/wp-json/wp/v2/users',
    headers={"Authorization": f"Basic {token}", "user-agent": "curl"},
)

Luckily, encoding the username/password string with basic authorization is such a common pattern that the request (and any other modern library) has a nice par for this.

import pystow, requests

username = pystow.get_config("isb", "wordpress_username", raise_on_missing=True)
password = pystow.get_config("isb", "wordpress_password", raise_on_missing=True)

res = requests.get(
    'https://www.biocuration.org/wp-json/wp/v2/users',
    auth=(username, password),
    headers={"user-agent": "curl"}
)

I burned a ton of time with this because it turns out that Wordpress blocks both the user agents for the request and httpx libraries. Therefore, you have to explicitly set the user-agent header to something else, or you get a HTTP 403 forbidden error.

2. The API doesn’t do what I wanted

It turns out that by default, all the users that never made a post get filtered out. This is bad since the only users who are making posts on the ISB are the handful who have or are currently on the executive committee.

So, obviously, the next step is to start injecting in PHP code to change how the API works. I found a comment from Tim Jensen, a Wordpress developer, that suggests adding the following hooks into the Wordpress theme’s functions.php file by navigating to https://www.biocuration.org/wp-admin/theme-editor.php?file=functions.php&theme=executive:

function remove_has_published_posts_from_api_user_query($prepared_args, $request)
{
    unset($prepared_args['has_published_posts']);
    return $prepared_args;
}
add_filter('rest_user_query', 'remove_has_published_posts_from_api_user_query', 10, 2);

This actually worked! But, it wasn’t the end of the story.

3. Full names are part of the plugin’s metadata

It turns out that the user data model isn’t all that comprehensive in Wordpress. What we really needed was the full name and email address for each person, and that was stored in an auxillary SQL table created and managed by the memberships plugin. Note that the plugin lives on top of Wordpress’s first-party users list, and doesn’t modify the API access to the user list.

So obviously, the next step was to add new API endpoints. I learned PHP at the ripe age of 14 and when I learned some other languages, I never looked back, so this wasn’t so easy. Plus, Wordpress has a huge set of functions and idioms on top. With a little help from ChatGPT, I was able to write a new API endpoint to add to functions.php that got users whose membership expiration date is in the future and joined in the metadata for their first and last names:

function export_pmp_members_to_csv() {
    global $wpdb;

    // Set headers to make the response downloadable as a CSV file
    header('Content-Type: text/csv');
    header('Content-Disposition: attachment; filename="members_export.csv";');
    header('Pragma: no-cache');
    header('Expires: 0');

    // Open output stream (in-memory file)
    $output = fopen('php://output', 'w');

    if (!$output) {
        return new WP_Error('file_error', 'Unable to generate CSV file.');
    }

    // Add CSV header row
    fputcsv($output, ['id', 'first', 'last', 'email', 'level', 'start', 'ends']);

    // Query to get PMP members and their membership details
    $results = $wpdb->get_results("
        SELECT 
            u.ID, 
            um_first_name.meta_value AS first_name, 
            um_last_name.meta_value AS last_name, 
            u.user_email, ml.name as membership_name, 
            mu.startdate, 
            mu.enddate
        FROM {$wpdb->users} u
        LEFT JOIN {$wpdb->prefix}pmpro_memberships_users mu ON u.ID = mu.user_id
        LEFT JOIN {$wpdb->prefix}pmpro_membership_levels ml ON mu.membership_id = ml.id
        LEFT JOIN {$wpdb->prefix}usermeta um_first_name ON u.ID = um_first_name.user_id 
            AND um_first_name.meta_key = 'first_name'
        LEFT JOIN {$wpdb->prefix}usermeta um_last_name ON u.ID = um_last_name.user_id 
            AND um_last_name.meta_key = 'last_name'
        WHERE mu.membership_id IS NOT NULL
            AND (mu.enddate > CURDATE() OR mu.enddate IS NULL)
    ");

    // Loop through results and write each row to the CSV
    foreach ($results as $row) {
        fputcsv($output, [
            $row->ID, 
            $row->first_name,
            $row->last_name,
            $row->user_email, 
            $row->membership_name, 
            $row->startdate, 
            $row->enddate
        ]);
    }

    // Close the output stream
    fclose($output);

    // Terminate script to ensure no extra output is sent
    exit;
}

function register_pmp_export_route() {
    // the actual path to the endpoint isn't important here
    register_rest_route('path/to/endpoint/', '/export-members', [
        'methods' => 'GET',
        'callback' => 'export_pmp_members_to_csv'
        'permission_callback' => function() {
            // Optionally, restrict access to logged-in users with specific capabilities
            return current_user_can('manage_options'); // Restrict to admin users
        }
    ]);
}
add_action('rest_api_init', 'register_pmp_export_route');

4. Automate it

Why stop at just being able to export the sheet? I wanted to go another mile and make sure that Wordpress sends an email to the right person at the journal on a monthly basis.

/ Function to generate and send the CSV via email
function send_pmp_members_csv_via_email() {
    global $wpdb;

    // Create a temporary file to store the CSV
    $tmp_file = tempnam(sys_get_temp_dir(), 'pmp_csv_') . '.csv';
    $output = fopen($tmp_file, 'w');

    if (!$output) {
        return; // Handle error appropriately
    }

    // Add CSV header row
    fputcsv($output, ['ID', 'Username', 'First Name', 'Last Name', 'Email', 'Membership Level Name', 'Joined Date', 'Expires Date']);

    // Query to get PMP members and their membership details
    $results = $wpdb->get_results("
        SELECT u.ID, 
            u.user_login, 
            um_first_name.meta_value AS first_name, 
            um_last_name.meta_value AS last_name,
            u.user_email, 
            ml.name as membership_name
            mu.startdate,
            mu.enddate,
        FROM {$wpdb->users} u
        LEFT JOIN {$wpdb->prefix}pmpro_memberships_users mu ON u.ID = mu.user_id
        LEFT JOIN {$wpdb->prefix}pmpro_membership_levels ml ON mu.membership_id = ml.id
        LEFT JOIN {$wpdb->prefix}usermeta um_first_name ON u.ID = um_first_name.user_id 
            AND um_first_name.meta_key = 'first_name'
        LEFT JOIN {$wpdb->prefix}usermeta um_last_name ON u.ID = um_last_name.user_id 
            AND um_last_name.meta_key = 'last_name'
        WHERE mu.membership_id IS NOT NULL
        AND (mu.enddate > CURDATE() OR mu.enddate IS NULL)
    ");

    // Write data to CSV
    foreach ($results as $row) {
        fputcsv($output, [
            $row->ID, 
            $row->user_login, 
            $row->first_name,
            $row->last_name,
            $row->user_email, 
            $row->membership_name, 
            $row->startdate, 
            $row->enddate
        ]);
    }

    // Close the file
    fclose($output);

    // Prepare email
    $to = ''; // Replace with recipient email
    $subject = 'ISB Monthly Members Report';
    $message = 'Please find the attached CSV file containing the list of active members of the International Society of Biocuration as of today.';
    $headers = array('Content-Type: text/csv; charset=UTF-8');

    // Send email with attachment
    wp_mail($to, $subject, $message, $headers, $tmp_file);

    // Clean up temporary file
    unlink($tmp_file);
}

// Schedule the cron job to run monthly
function schedule_monthly_csv_email() {
    if (!wp_next_scheduled('monthly_pmp_csv_email')) {
        wp_schedule_event(time(), 'monthly', 'monthly_pmp_csv_email');
    }
}
add_action('wp', 'schedule_monthly_csv_email');

// Hook the function to the cron event
add_action('monthly_pmp_csv_email', 'send_pmp_members_csv_via_email');

Hopefully I don’t have to write anymore PHP for a long time :)