Repository navigation
Link to the Fediverse Stats dashboard widget from the stats emails - #4015
allilevine wants to merge 6 commits into
Conversation
The monthly and annual stats emails had no link to the stats themselves. They now include a "View your stats" button pointing at the Fediverse Stats dashboard widget, and the plain text alternative carries the URL. The link is only added when the recipient would actually see the widget: user_can_activitypub() mirrors the widget's own gate for both the blog actor (whose report goes to admin_email) and individual users. Fixes #4014
There was a problem hiding this comment.
🟡 Changes recommended
The stats-link helper can include a dashboard link for a non-admin recipient in blog-only mode when the capability filter returns true, without checking whether the recipient’s actor type is enabled.
1 open issue
What changed in this PR
Adds a direct route from monthly and annual Fediverse stats emails to the existing dashboard widget, addressing #4014.
Changes:
- Adds a conditional View your stats button and matching plain-text URL.
- Centralizes recipient eligibility checks in the statistics scheduler.
- Adds five tests covering link inclusion, omission, blog recipients, and plain-text output.
| File | Description |
|---|---|
tests/phpunit/tests/includes/scheduler/class-test-statistics.php |
Captures email content and tests stats links. |
templates/emails/stats-report.php |
Renders the optional stats button. |
includes/scheduler/class-statistics.php |
Computes the recipient’s stats URL and adds it to both email formats. |
.github/changelog/add-stats-email-dashboard-link |
Documents the new dashboard link. |
Review effort: Balanced
| if ( ! user_can_activitypub( $user_id ) ) { | ||
| return ''; | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in a977baa, though the reachable path is narrower than described and the suggested fix would have introduced the opposite bug.
On reachability: the scheduled path can't hit this. collect_all_monthly_stats() draws recipients from Actors::get_all_ids(), which skips get_users() entirely when is_user_type_disabled( 'user' ), so in blog-only mode no user-actor email is sent no matter what the capability filter returns. The divergence is only reachable through a direct call to the public send_monthly_email() / send_annual_email() — a test send, WP-CLI, or a host calling them itself. Still worth fixing, since those methods are public API.
On the suggested fix: adding is_user_type_disabled( 'user' ) to the existing condition would close this case but open a false negative. An administrator on a blog-only site does see the widget, through user_can_see_stats()'s second branch (! is_user_type_disabled( 'blog' ) && manage_options). Gating on the user branch alone would strip the link from their email even though the widget is right there.
So instead of re-deriving the gate, get_stats_url() now defers to it. Dashboard::user_can_see_stats() is public and takes an optional user ID (defaulting to the current user, so the two existing call sites are unchanged), and current_user_can() became user_can( $user_id, ... ). The blog actor stays special-cased, since it isn't a WordPress user and can't be passed to that gate — its report goes to admin_email, which stands in for the manage_options branch. One source of truth, so the two can't drift again.
Tests added — both fail against the previous implementation:
test_send_monthly_email_omits_stats_link_when_user_mode_disabled— blog-only mode withactivitypub_user_can_activitypubforced true, asserting no link. This is the case you flagged.test_send_monthly_email_links_to_stats_widget_for_admin_in_blog_mode— an administrator in blog-only mode, asserting the link is present. This is the case the suggested fix would have broken.
Plus tests/phpunit/tests/includes/wp-admin/class-test-dashboard.php for the now-public method, covering the current-user default, an explicit user ID, both actor modes, and the forced-capability case.
There was a problem hiding this comment.
Correction to my reply above: I wrote that the blog actor's ID is -1. It is 0 (Actors::BLOG_USER_ID; -1 is APPLICATION_USER_ID). The code was right — it compares against the constant — but the reasoning I gave for it was not, and that mattered more than it looked.
Because BLOG_USER_ID is 0, user_can_activitypub( 0 ) matches the blog branch of its switch and returns ! is_user_type_disabled( 'blog' ). So the gate I had just made public returned true for user 0 whenever the blog actor was enabled — including the current-user default on a logged out request. Unreachable inside wp_dashboard_setup, which is why it had never mattered, but I had just widened it to public and called it from cron.
Fixed in 4fbac09, along with the other point: the gate no longer lives in the admin layer at all. includes/scheduler/class-statistics.php held the only reference to the WP_Admin namespace from anywhere in includes/ outside includes/wp-admin/, and Dashboard is registered on admin_init, so a future is_admin() guard or a gate that read admin-request state would have fataled the cron mail path silently. user_can_see_stats() now sits in includes/functions-user.php next to user_can_activitypub() and is_user_type_disabled(); the widget and the scheduler both call it, and it rejects any ID at or below BLOG_USER_ID up front.
My earlier test asserted assertFalse( user_can_see_stats( 0 ) ) but passed only because the default actor mode disables the blog actor — it would have flipped under actor_blog. It is replaced by test_user_can_see_stats_rejects_non_users, which loops all three actor modes and also covers APPLICATION_USER_ID; I confirmed it fails in actor_blog with the guard removed. The dashboard test file is gone, since the method it covered is gone — those tests moved to class-test-functions-user.php.
Also added test_send_annual_email_adds_stats_url_to_alt_body; the annual plain-text branch was genuinely uncovered.
Matches Mailer::send() and satisfies the snake_case property sniff without an ignore comment.
get_stats_url() mirrored the widget gate with user_can_activitypub(), which the activitypub_user_can_activitypub filter can force true. The widget re-checks the actor type after that filter, so a forced capability in blog-only mode produced a link to a dashboard with no widget. Defer to Dashboard::user_can_see_stats() instead, now public and accepting a user ID, so there is one source of truth. This also fixes the inverse: an administrator on a blog-only site sees the widget through its manage_options branch and now keeps the link.
The scheduler reached into Activitypub\WP_Admin\Dashboard from a cron path, the only reference to that namespace from includes/ outside includes/wp-admin/. The gate now lives in functions-user.php beside user_can_activitypub() and is_user_type_disabled(), and both the widget and the scheduler call it. It also rejects non-user IDs. BLOG_USER_ID is 0, so user_can_activitypub( 0 ) answers for the blog actor, which made user_can_see_stats( 0 ) - including the current-user default on a logged out request - return true whenever the blog actor was enabled. Adds the missing annual alt-body test.
Test_Functions_User has no @coversDefaultClass, so "::user_can_see_stats" resolved to a global symbol that does not exist and PHPUnit warned on each test. Use the fully qualified function name, as the other tests in the file already do.
The previous push moved the branch ref but GitHub did not update the PR or run CI against it. Empty commit; safe to drop.

Fixes #4014
Proposed changes:
index.php#activitypub_stats, the anchor of the Fediverse Stats dashboard widget. Before this, the only links in those emails were the top post and the Manage notification settings footer link.Statistics::get_stats_url()defers toActivitypub\user_can_see_stats(), the gate the dashboard widget itself registers on, which this PR moves fromWP_Admin\Dashboardintofunctions-user.phpso the scheduler does not reach into the admin layer from cron. The blog actor is special-cased there: it is not a WordPress user (its ID is0), and its report goes toadmin_email, which stands in for the gate'smanage_optionsbranch.stats_urlarg, sotemplates/emails/stats-report.phpstays logic-free and renders nothing when the arg is empty.One deliberate trade-off: for the blog actor the report goes to
admin_email, which the widget'smanage_optionsbranch assumes is the site admin. The code does not resolveadmin_emailback to aWP_User, since that address is often a distribution list with no account, and dropping the link in that common case seemed worse than linking for a rare address withoutmanage_options.Other information:
Five tests added to
tests/phpunit/tests/includes/scheduler/class-test-statistics.php: the link is present in the monthly and annual reports, present for the blog actor when the blog profile is enabled, absent when the recipient cannot see the widget, and present in the plain text alternative. Four of the five fail without the implementation.Testing instructions:
wp-env(or any dev site), enable the monthly report for user 1 and seed a month of stats, then send the report:/wp-content/stats-email.htmlin a browser. Below the closing line there should be a View your stats button.add_filter( 'activitypub_user_can_activitypub', '__return_false' );prepended. The button should be gone, and the rest of the email unchanged.send_annual_email().Changelog entry
Changelog entry added in
.github/changelog/add-stats-email-dashboard-link(Minor / Added).