logger = $logger;
$this->settings_page = $settings_page;
}
/**
* Hooks.
*
* @return void
*/
public function init() {
$this->settings_page->init();
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'admin_notices', array( $this, 'dependency_notice' ) );
add_action( 'admin_post_evlfl_generate_test_data', array( $this, 'handle_generate_test_data' ) );
add_action( 'admin_post_evlfl_export_csv', array( $this, 'handle_export_csv' ) );
}
/**
* Menu: top-level "Enrollment Logs" with "View Logs" and "Logs Settings".
*
* @return void
*/
public function register_menu() {
$cap = EVLFL_Plugin::capability();
$title = __( 'Enrollment Logs', 'accesstrace-for-learndash' );
$this->hook_suffixes[] = (string) add_menu_page( $title, $title, $cap, self::PAGE_SLUG, array( $this, 'render_page' ), 'dashicons-list-view', 81 );
// Same slug as the parent: renames the first submenu entry from "Enrollment Logs" to "View Logs".
add_submenu_page( self::PAGE_SLUG, __( 'View Logs', 'accesstrace-for-learndash' ), __( 'View Logs', 'accesstrace-for-learndash' ), $cap, self::PAGE_SLUG, array( $this, 'render_page' ) );
$this->hook_suffixes[] = (string) add_submenu_page( self::PAGE_SLUG, __( 'Logs Settings', 'accesstrace-for-learndash' ), __( 'Logs Settings', 'accesstrace-for-learndash' ), $cap, self::SETTINGS_SLUG, array( $this->settings_page, 'render' ) );
}
/**
* Enqueue CSS on our screen only.
*
* @param string $hook Hook suffix.
* @return void
*/
public function enqueue_assets( $hook ) {
if ( in_array( $hook, $this->hook_suffixes, true ) ) {
$file = EVLFL_PLUGIN_DIR . 'assets/css/admin.css';
// filemtime, not the plugin version, so every edit to the stylesheet busts the
// browser cache on its own; a visitor never has to hard-refresh after an update.
$ver = file_exists( $file ) ? (string) filemtime( $file ) : EVLFL_VERSION;
wp_enqueue_style( 'evl-admin', EVLFL_PLUGIN_URL . 'assets/css/admin.css', array( 'dashicons' ), $ver );
}
}
/**
* Notice when LearnDash is missing (once per request; limited to relevant screens).
*
* @return void
*/
public function dependency_notice() {
if ( self::$notice_printed || EVLFL_LearnDash::is_available() || ! current_user_can( EVLFL_Plugin::capability() ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$id = $screen ? $screen->id : '';
if ( ! in_array( $id, array( 'dashboard', 'plugins' ), true ) && false === strpos( $id, self::PAGE_SLUG ) && false === strpos( $id, self::SETTINGS_SLUG ) ) {
return;
}
self::$notice_printed = true;
printf(
'
',
esc_html__( 'AccessTrace for LearnDash requires LearnDash to be active. No enrollment events will be logged until LearnDash is available.', 'accesstrace-for-learndash' )
);
}
/**
* Page router.
*
* @return void
*/
public function render_page() {
if ( ! current_user_can( EVLFL_Plugin::capability() ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) );
}
$event_id = isset( $_GET['evlfl_event'] ) ? absint( wp_unslash( $_GET['evlfl_event'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view.
if ( $event_id > 0 ) {
$row = $this->logger->get( $event_id );
$list_url = add_query_arg( 'page', self::PAGE_SLUG, admin_url( 'admin.php' ) );
$view = $row ? EVLFL_Log_Details::build( $row ) : null;
include EVLFL_PLUGIN_DIR . 'admin/views/log-details.php';
return;
}
self::load_list_table();
$stats = $this->logger->status_counts();
$table = new EVLFL_Log_List_Table( $this->logger );
$table->set_overall_total( $stats['total'] );
$table->prepare_items();
$test_data_enabled = EVLFL_Test_Data::is_enabled();
$notice = $this->read_notice();
$refresh_url = $this->refresh_url();
$export_url = $this->export_url();
include EVLFL_PLUGIN_DIR . 'admin/views/logs.php';
}
/**
* The list table class (and its parent, WP_List_Table) are not loaded by WordPress on
* every admin request - admin-post.php, for one, does not load them. Everything that
* uses EVLFL_Log_List_Table goes through here first.
*
* @return void
*/
public static function load_list_table() {
require_once EVLFL_PLUGIN_DIR . 'admin/class-log-list-table.php';
}
/**
* "Refresh": this screen with the current filters, sort and page, so it re-runs the same view.
*
* @return string Raw URL (escape on output).
*/
public function refresh_url() {
self::load_list_table();
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only view state, every value validated.
$args = array_merge( EVLFL_Log_List_Table::url_args( EVLFL_Log_List_Table::parse_filters( $_GET ) ), EVLFL_Log_List_Table::sort_args( $_GET ) );
$paged = isset( $_GET['paged'] ) && is_scalar( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 0;
// phpcs:enable
if ( $paged > 1 ) {
$args['paged'] = $paged;
}
return EVLFL_Log_List_Table::list_url( $args );
}
/**
* "Export CSV": admin-post endpoint with the current filters and a nonce.
*
* @return string Raw URL (escape on output).
*/
public function export_url() {
self::load_list_table();
$args = EVLFL_Log_List_Table::url_args( EVLFL_Log_List_Table::parse_filters( $_GET ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- validated.
$url = add_query_arg( rawurlencode_deep( array_merge( array( 'action' => 'evlfl_export_csv' ), $args ) ), admin_url( 'admin-post.php' ) );
return add_query_arg( '_wpnonce', wp_create_nonce( 'evlfl_export_csv' ), $url );
}
/**
* Download the currently filtered events as CSV. Read-only: nothing is changed.
*
* @return void
*/
public function handle_export_csv() {
if ( ! current_user_can( EVLFL_Plugin::capability() ) ) {
wp_die( esc_html__( 'You do not have permission to do that.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'evlfl_export_csv' );
self::load_list_table();
$filters = EVLFL_Log_List_Table::parse_filters( $_GET ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above; values validated by parse_filters().
$filters['search_email'] = EVLFL_Log_List_Table::can_search_email();
$filename = 'enrollment-logs-' . wp_date( 'Y-m-d-His' ) . '.csv';
if ( ! $this->send_download_headers( $filename ) ) {
wp_die( esc_html__( 'The export could not start because another plugin already sent output. Please try again.', 'accesstrace-for-learndash' ), '', array( 'response' => 500 ) );
}
self::allow_long_request();
$out = fopen( 'php://output', 'w' );
$this->write_csv( $out, $filters );
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the php://output stream, not a file.
$this->end_request();
}
/**
* A big log takes a while to stream: use WordPress's (higher) admin memory limit.
* Rows are streamed in keyset batches with names looked up once per batch, so the
* export stays fast without changing the PHP time limit.
*
* @return void
*/
private static function allow_long_request() {
if ( function_exists( 'wp_raise_memory_limit' ) ) {
wp_raise_memory_limit( 'admin' );
}
}
/**
* CSV download headers.
*
* @param string $filename File name offered to the browser.
* @return bool False when output has already started (headers can no longer be sent).
*/
protected function send_download_headers( $filename ) {
if ( headers_sent() ) {
return false;
}
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $filename ) . '"' );
header( 'X-Content-Type-Options: nosniff' );
return true;
}
/**
* Ends the download request. A separate method so tests can run the handler without exiting.
*
* @return void
*/
protected function end_request() {
exit;
}
/**
* Write the CSV: UTF-8 BOM (so Excel shows accented names correctly), a header row, then one
* row per matching event, newest first.
*
* @param resource $handle Writable stream.
* @param array $filters Output of EVLFL_Log_List_Table::parse_filters().
* @return int Number of event rows written.
*/
public function write_csv( $handle, array $filters ) {
fwrite( $handle, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming to php://output.
self::put_csv(
$handle,
array(
__( 'Event ID', 'accesstrace-for-learndash' ),
__( 'Date (site time)', 'accesstrace-for-learndash' ),
__( 'Date (UTC)', 'accesstrace-for-learndash' ),
__( 'User', 'accesstrace-for-learndash' ),
__( 'User ID', 'accesstrace-for-learndash' ),
__( 'Course', 'accesstrace-for-learndash' ),
__( 'Course ID', 'accesstrace-for-learndash' ),
__( 'Action', 'accesstrace-for-learndash' ),
__( 'Source', 'accesstrace-for-learndash' ),
__( 'Trigger', 'accesstrace-for-learndash' ),
__( 'Status', 'accesstrace-for-learndash' ),
__( 'Confidence', 'accesstrace-for-learndash' ),
__( 'Reason', 'accesstrace-for-learndash' ),
)
);
// Names are looked up once per batch (not once per row), and only the current batch's
// names are kept, so memory stays flat however long the log is.
$users = array();
$on_batch = static function ( array $rows ) use ( &$users ) {
$users = array();
$ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $rows, 'user_id' ) ) ) );
$found = get_users(
array(
'include' => $ids,
'fields' => array( 'ID', 'display_name' ),
'blog_id' => 0,
'count_total' => false,
)
);
foreach ( $found as $u ) {
$users[ (int) $u->ID ] = (string) $u->display_name;
}
if ( function_exists( '_prime_post_caches' ) ) {
_prime_post_caches( array_values( array_unique( array_map( 'intval', wp_list_pluck( $rows, 'course_id' ) ) ) ), false, false );
}
};
return $this->logger->each(
$filters,
static function ( array $row ) use ( $handle, &$users ) {
$name = isset( $users[ $row['user_id'] ] ) ? $users[ $row['user_id'] ] : null;
$course = get_post( $row['course_id'] );
self::put_csv(
$handle,
array(
(int) $row['id'],
EVLFL_Log_Details::format_time( $row['event_time'] ),
(string) $row['event_time'],
/* translators: %d: user ID */
null !== $name ? $name : sprintf( __( '[Deleted User #%d]', 'accesstrace-for-learndash' ), $row['user_id'] ),
(int) $row['user_id'],
/* translators: %d: course ID */
$course ? $course->post_title : sprintf( __( '[Deleted Course #%d]', 'accesstrace-for-learndash' ), $row['course_id'] ),
(int) $row['course_id'],
EVLFL_Event::label( 'action', $row['action'] ),
EVLFL_Event::label( 'source', $row['source'] ),
EVLFL_Event::label( 'trigger', $row['event_trigger'] ),
EVLFL_Event::label( 'status', $row['status'] ),
EVLFL_Event::label( 'confidence', $row['confidence'] ),
(string) $row['verification_reason'],
)
);
},
500,
$on_batch
);
}
/**
* One RFC 4180 CSV line. Text cells that a spreadsheet would run as a formula (they start
* with = + - @, tab or CR - e.g. a course titled "=HYPERLINK(...)") get a leading
* apostrophe, so opening the export can never execute anything.
*
* @param resource $handle Writable stream.
* @param array $cells Cell values.
* @return void
*/
public static function put_csv( $handle, array $cells ) {
foreach ( $cells as $i => $cell ) {
if ( is_int( $cell ) ) {
continue;
}
$cell = (string) $cell;
if ( '' !== $cell && false !== strpos( "=+-@\t\r", $cell[0] ) ) {
$cell = "'" . $cell;
}
$cells[ $i ] = $cell;
}
fputcsv( $handle, $cells, ',', '"', '' );
}
/**
* Allowlisted notice key from the query string.
*
* @return string
*/
private function read_notice() {
$key = isset( $_GET['evlfl_notice'] ) ? sanitize_key( wp_unslash( $_GET['evlfl_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return in_array( $key, array( 'generated' ), true ) ? $key : '';
}
/**
* Generate sample events (constant-gated, capability + nonce protected).
*
* @return void
*/
public function handle_generate_test_data() {
if ( ! current_user_can( EVLFL_Plugin::capability() ) || ! EVLFL_Test_Data::is_enabled() ) {
wp_die( esc_html__( 'You do not have permission to do that.', 'accesstrace-for-learndash' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'evlfl_generate_test_data' );
EVLFL_Test_Data::generate( $this->logger, get_current_user_id() );
wp_safe_redirect(
add_query_arg(
array(
'page' => self::PAGE_SLUG,
'evlfl_notice' => 'generated',
),
admin_url( 'admin.php' )
)
);
exit;
}
}