content = ''; try { self::guard_permission(); $self = self::instance(); $report = sanitize_key( $data['report'] ?? '' ); $paged = max( 1, absint( $data['paged'] ?? 1 ) ); $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) ); $per_page = self::PER_PAGE; $total = 0; $rows = $self->fetch_rows( $report, $data, $paged, $per_page, $total ); /** * Filter the fetched report rows before the table / CSV is built. * * @param array $rows Row data for the current page. * @param string $report Report id. * @param array $data Sent args. * @since 4.4.2 */ $rows = (array) apply_filters( 'learn-press/statistics/report/rows', $rows, $report, $data ); $columns = $self->columns_for( $report, $rows ); /** * Filter the report column specs before the table / CSV is built. * * Each column: [ 'label', 'class'?, 'key'?, 'render'?( row ):html, 'csv'?( row ):string ]. * A 'render' callback owns its own escaping ( its return is echoed as cell HTML ). * * @param array $columns Column specs. * @param string $report Report id. * @param array $rows Current page rows. * @since 4.4.2 */ $columns = (array) apply_filters( 'learn-press/statistics/report/columns', $columns, $report, $rows ); if ( empty( $columns ) ) { throw new Exception( esc_html__( 'Unknown report.', 'learnpress' ) ); } $content->content = $self->capped_notice( $report, $total ) . $self->html_table( $report, $rows, $columns, $paged, $per_page, $total ); } catch ( Throwable $e ) { $content->content = Template::print_message( $e->getMessage(), 'error', false ); LP_Debug::error_log( $e ); } return $content; } /** * Notice shown when a PHP-merged report is truncated at max_rows(). Those * reports ( top_courses / course_performance ) merge two query result sets * in PHP and paginate the merged array, so rows beyond the cap are not * reachable — surface that instead of silently hiding them. Empty string * for SQL-paginated reports or when the cap was not hit. * * @param string $report * @param int $total * @return string * @since 4.4.2 */ private function capped_notice( string $report, int $total ): string { $php_merged = array( 'top_courses', 'course_performance' ); if ( ! in_array( $report, $php_merged, true ) || $total < self::max_rows() ) { return ''; } $message = sprintf( /* translators: %d: maximum number of rows shown. */ __( 'Showing the first %d rows. Narrow the period or filters to see the rest.', 'learnpress' ), self::max_rows() ); return '
' . esc_html( $message ) . '
'; } /** * AJAX callback: build the full (capped) result set as a CSV string. * * @param array $data * @return stdClass { csv, filename } */ public static function render_report_csv( array $data ): stdClass { $out = new stdClass(); $out->csv = ''; $out->filename = 'learnpress-report.csv'; try { self::guard_permission(); $self = self::instance(); $report = sanitize_key( $data['report'] ?? '' ); $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) ); $total = 0; // One page big enough to hold everything ( capped ). $rows = $self->fetch_rows( $report, $data, 1, self::max_rows(), $total ); /** This filter is documented in inc/TemplateHooks/Admin/AdminStatisticsReportTable.php */ $rows = (array) apply_filters( 'learn-press/statistics/report/rows', $rows, $report, $data ); $columns = $self->columns_for( $report, $rows ); /** This filter is documented in inc/TemplateHooks/Admin/AdminStatisticsReportTable.php */ $columns = (array) apply_filters( 'learn-press/statistics/report/columns', $columns, $report, $rows ); if ( ! empty( $columns ) ) { $out->csv = $self->build_csv( $columns, $rows ); $out->filename = $self->csv_filename( $report, $data ); } } catch ( Throwable $e ) { LP_Debug::error_log( $e ); } return $out; } /** * @throws Exception */ private static function guard_permission() { $allowed = apply_filters( 'learnpress/admin-statistics/permission', current_user_can( 'administrator' ) ); if ( ! $allowed ) { throw new Exception( esc_html__( 'You do not have permission to view this report.', 'learnpress' ) ); } } /** * Map the request filtertype/date into [ filter_type, time ]. * Same PeriodResolver as LP_REST_Admin_Statistics_Controller::get_statistics_filter(), * so report popups/CSV honor every preset — new and legacy — identically. * * @param array $data * @return array */ private function resolve_filter( array $data ): array { $range = PeriodResolver::resolve( (string) ( $data['filtertype'] ?? 'today' ), (string) LP_Helper::sanitize_params_submitted( $data['date'] ?? '' ) ); return $range->legacy_pair(); } /** * Fetch one page of rows for a report ( total set by-ref ). * * @param string $report * @param array $data * @param int $paged * @param int $limit * @param int $total * @return array */ private function fetch_rows( string $report, array $data, int $paged, int $limit, int &$total ): array { $filter = $this->resolve_filter( $data ); $scope = StatisticsScope::from_params( $data ); $type = $filter['filter_type']; $time = (string) $filter['time']; $offset = ( $paged - 1 ) * $limit; $db = DashboardStatisticsDB::getInstance(); $search = trim( (string) LP_Helper::sanitize_params_submitted( $data['search'] ?? '' ) ); /** * Filter the report query args before fetching a page of rows. * * Bounded scalars only ( no raw SQL ); every value is re-sanitized below * so a handler cannot smuggle unsafe input. Use to change page size, * inject a default search term, or repoint the report. * * @param array $args [ report, paged, limit, search ]. * @param string $report Report id. * @param array $data Raw sent args. * @since 4.4.2 */ $args = apply_filters( 'learn-press/statistics/report/query-args', array( 'report' => $report, 'paged' => $paged, 'limit' => $limit, 'search' => $search, ), $report, $data ); $report = sanitize_key( $args['report'] ?? $report ); $paged = max( 1, absint( $args['paged'] ?? $paged ) ); $limit = max( 1, absint( $args['limit'] ?? $limit ) ); $search = trim( (string) ( $args['search'] ?? $search ) ); $offset = ( $paged - 1 ) * $limit; // Derived from the resolved paged/limit so it can never desync. switch ( $report ) { case 'top_courses': $all = $db->get_top_courses_performance( $type, $time, $scope, self::max_rows(), $search ); $total = count( $all ); return $this->enrich_course_rows( $db, array_slice( $all, $offset, $limit ) ); case 'course_performance': $all = $db->get_top_courses_performance( $type, $time, $scope, self::max_rows(), $search ); $total = count( $all ); return $this->format_course_performance_rows( $db, array_slice( $all, $offset, $limit ) ); case 'top_sold_courses': $total = $db->count_top_sold_courses( $type, $time, $scope, $search ); $sold = array_map( function ( $row ) { $row['revenue_formatted'] = html_entity_decode( learn_press_format_price( $row['revenue'] ) ); $row['aov_formatted'] = null !== $row['aov'] ? html_entity_decode( learn_press_format_price( $row['aov'] ) ) : null; return $row; }, $db->get_top_sold_courses_detailed( $type, $time, $scope, $limit, $offset, $search ) ); return $this->attach_revenue_trend( $db, $filter, $scope, $sold ); case 'exceptions': $status = sanitize_key( $data['order_status'] ?? '' ); $provider = OrderExceptionsProvider::getInstance(); $total = $provider->count_exceptions( $type, $time, $scope, $search, $status ); return $provider->get_exceptions( $type, $time, $scope, $limit, $offset, $search, $status ); case 'top_students': $total = $db->count_top_students( $type, $time, $scope, $search ); return $db->get_top_students( $type, $time, $scope, $limit, $offset, $search ); case 'courses_by_students': $total = $db->count_courses_by_students( $type, $time, $scope, $search ); return $db->get_courses_by_students( $type, $time, $scope, $limit, $offset, $search ); case 'instructor_performance': $total = $db->count_instructor_performance( $type, $time, $scope, $search ); return InstructorStatisticsProvider::format_performance( $db->get_instructor_performance( $type, $time, $scope, $limit, $offset, $search ) ); case 'instructor_report': $instructor_id = absint( $data['instructor_id'] ?? 0 ); $report_data = InstructorStatisticsProvider::get_report( $instructor_id, $paged, $limit, $search ); $total = (int) ( $report_data['total'] ?? 0 ); return $report_data['rows'] ?? array(); default: $total = 0; return array(); } } /** * Shape merged course-performance rows for the Courses report ( name, * instructor, revenue, enrollments, completion ). Self-contained so the AJAX * router never depends on the REST controller being loaded. * * @param DashboardStatisticsDB $db * @param array $rows From get_top_courses_performance(). * @return array */ private function format_course_performance_rows( DashboardStatisticsDB $db, array $rows ): array { $course_ids = array_map( function ( $row ) { return absint( $row['course_id'] ?? 0 ); }, $rows ); $instructors = $db->get_course_instructor_names( $course_ids ); $categories = $db->get_course_category_names( $course_ids ); return array_map( function ( $row ) use ( $instructors, $categories ) { $course_id = absint( $row['course_id'] ?? 0 ); $revenue = (float) ( $row['revenue'] ?? 0 ); $cats = $categories[ $course_id ] ?? array(); return array( 'course_id' => $course_id, 'name' => (string) ( $row['course_name'] ?? '' ), 'category' => implode( ', ', $cats ), // all — CSV 'category_primary' => $cats[0] ?? '', // first — table cell 'instructor' => $instructors[ $course_id ] ?? '', 'revenue' => $revenue, 'revenue_formatted' => html_entity_decode( learn_press_format_price( $revenue ) ), 'orders' => (int) ( $row['order_count'] ?? 0 ), 'enrollments' => (int) ( $row['enrolled'] ?? 0 ), 'completed' => (int) ( $row['completed'] ?? 0 ), 'completion_rate' => $row['completion_rate'] ?? null, 'edit_link' => $course_id > 0 ? (string) get_edit_post_link( $course_id, 'raw' ) : '', ); }, $rows ); } /** * Attach instructor + category names and formatted revenue to raw * top-course rows ( keys kept: course_name/order_count/enrolled/... ). * * @param DashboardStatisticsDB $db * @param array $rows From get_top_courses_performance(). * @return array */ private function enrich_course_rows( DashboardStatisticsDB $db, array $rows ): array { $course_ids = array_map( function ( $row ) { return absint( $row['course_id'] ?? 0 ); }, $rows ); $instructors = $db->get_course_instructor_names( $course_ids ); $categories = $db->get_course_category_names( $course_ids ); return array_map( function ( $row ) use ( $instructors, $categories ) { $course_id = absint( $row['course_id'] ?? 0 ); $cats = $categories[ $course_id ] ?? array(); $row['instructor'] = $instructors[ $course_id ] ?? ''; $row['category'] = implode( ', ', $cats ); // all — CSV $row['category_primary'] = $cats[0] ?? ''; // first — table cell $row['revenue_formatted'] = html_entity_decode( learn_press_format_price( $row['revenue'] ?? 0 ) ); return $row; }, $rows ); } /** * Add a revenue "trend" ( vs the equivalent previous period ) to sold-course * rows. One extra query for the page's course IDs; no-op on an empty page. * * @param DashboardStatisticsDB $db * @param array $filter [ filter_type, time ] for the current range. * @param StatisticsScope|null $scope * @param array $rows * @return array */ private function attach_revenue_trend( DashboardStatisticsDB $db, array $filter, ?StatisticsScope $scope, array $rows ): array { if ( empty( $rows ) ) { return $rows; } $prev = PeriodHelper::get_previous_filter( $filter ); $prev_map = array(); if ( $prev ) { $course_ids = array_map( function ( $row ) { return absint( $row['course_id'] ?? 0 ); }, $rows ); $prev_map = $db->get_course_revenue_totals( (string) ( $prev['filter_type'] ?? '' ), (string) ( $prev['time'] ?? '' ), $scope, $course_ids ); } return array_map( function ( $row ) use ( $prev_map ) { $current = (float) ( $row['revenue'] ?? 0 ); $previous = (float) ( $prev_map[ absint( $row['course_id'] ?? 0 ) ] ?? 0 ); $row['trend'] = $this->trend_direction( $current, $previous ); $row['trend_pct'] = $this->trend_pct( $current, $previous ); return $row; }, $rows ); } /** * @param float $current * @param float $previous * @return string up|down|flat */ private function trend_direction( float $current, float $previous ): string { if ( $current > $previous ) { return 'up'; } if ( $current < $previous ) { return 'down'; } return 'flat'; } /** * @param float $current * @param float $previous * @return float|null Percentage change, or null when there is no prior baseline. */ private function trend_pct( float $current, float $previous ) { if ( $previous <= 0 ) { return null; } return round( ( $current - $previous ) / $previous * 100, 1 ); } /** * @param array $row * @return string HTML trend badge ( arrow + % ). */ private function trend_cell( array $row ): string { $dir = (string) ( $row['trend'] ?? 'flat' ); $pct = $row['trend_pct'] ?? null; $arrows = array( 'up' => '▲', 'down' => '▼', 'flat' => '—', ); $colors = array( 'up' => 'green', 'down' => 'red', 'flat' => 'grey', ); $arrow = $arrows[ $dir ] ?? '—'; $color = $colors[ $dir ] ?? 'grey'; if ( null !== $pct ) { $label = $arrow . ' ' . abs( $pct ) . '%'; } elseif ( 'up' === $dir ) { $label = $arrow . ' ' . __( 'New', 'learnpress' ); } else { $label = $arrow; } return $this->badge( $color, $label ); } /** * @param array $row * @return string CSV representation of the trend. */ private function trend_csv( array $row ): string { $pct = $row['trend_pct'] ?? null; if ( null === $pct ) { return 'up' === ( $row['trend'] ?? '' ) ? __( 'New', 'learnpress' ) : ''; } return $pct . '%'; } /** * Column specs for a report. Each column: * [ 'label', 'class'?, 'key'?, 'render'?( row ):html, 'csv'?( row ):string ] * * @param string $report * @param array $rows Current page ( used for conditional columns ). * @return array */ private function columns_for( string $report, array $rows ): array { $i18n = array( 'course' => __( 'Course', 'learnpress' ), 'category' => __( 'Category', 'learnpress' ), 'instructor' => __( 'Instructor', 'learnpress' ), 'revenue' => __( 'Revenue', 'learnpress' ), 'total_revenue' => __( 'Total revenue', 'learnpress' ), 'orders' => __( 'Orders', 'learnpress' ), 'enrolled' => __( 'Enrolled', 'learnpress' ), 'enrollments' => __( 'Enrollments', 'learnpress' ), 'completion' => __( 'Completion', 'learnpress' ), 'avg_completion' => __( 'Avg completion', 'learnpress' ), 'completed' => __( 'Completed', 'learnpress' ), 'started' => __( 'Started', 'learnpress' ), 'active_7d' => __( 'Active 7d', 'learnpress' ), 'student' => __( 'Student', 'learnpress' ), 'students' => __( 'Students', 'learnpress' ), 'active_students' => __( 'Active students', 'learnpress' ), 'courses' => __( 'Courses', 'learnpress' ), 'courses_managed' => __( 'Courses managed', 'learnpress' ), 'status' => __( 'Status', 'learnpress' ), 'trend' => __( 'Trend', 'learnpress' ), 'aov' => __( 'AOV', 'learnpress' ), 'order_id' => __( 'Order ID', 'learnpress' ), 'issue' => __( 'Issue', 'learnpress' ), 'date' => __( 'Date', 'learnpress' ), 'severity' => __( 'Severity', 'learnpress' ), 'avg_score' => __( 'Quiz pass rate', 'learnpress' ), 'last_active' => __( 'Last active', 'learnpress' ), 'sold' => __( 'Sold', 'learnpress' ), ); $col_text = function ( $key, $label ) { return array( 'label' => $label, 'key' => $key, ); }; $col_revenue = function ( $label = null ) use ( $i18n ) { return array( 'label' => $label ?: $i18n['revenue'], 'render' => function ( $row ) { return esc_html( (string) ( $row['revenue_formatted'] ?? '' ) ); }, 'csv' => function ( $row ) { return (string) ( $row['revenue'] ?? 0 ); }, ); }; $col_completion = function ( $key, $label ) { return array( 'label' => $label, 'render' => function ( $row ) use ( $key ) { return $this->completion_cell( $row[ $key ] ?? null ); }, 'csv' => function ( $row ) use ( $key ) { $val = $row[ $key ] ?? null; return null === $val ? '' : $val . '%'; }, ); }; // Table shows only the primary category; CSV exports every category. $col_category = function () use ( $i18n ) { return array( 'label' => $i18n['category'], 'render' => function ( $row ) { return esc_html( (string) ( $row['category_primary'] ?? '' ) ); }, 'csv' => function ( $row ) { return (string) ( $row['category'] ?? '' ); }, ); }; switch ( $report ) { case 'top_courses': return array( $col_text( 'course_name', $i18n['course'] ), $col_category(), $col_text( 'instructor', $i18n['instructor'] ), $col_revenue(), $col_text( 'order_count', $i18n['orders'] ), $col_text( 'enrolled', $i18n['enrollments'] ), $col_completion( 'completion_rate', $i18n['completion'] ), ); case 'course_performance': return array( $col_text( 'name', $i18n['course'] ), $col_category(), $col_text( 'instructor', $i18n['instructor'] ), $col_revenue(), $col_text( 'orders', $i18n['orders'] ), $col_text( 'enrollments', $i18n['enrollments'] ), $col_completion( 'completion_rate', $i18n['completion'] ), ); case 'top_sold_courses': return array( $col_text( 'name', $i18n['course'] ), $col_revenue(), $col_text( 'orders', $i18n['orders'] ), array( 'label' => $i18n['aov'], 'render' => function ( $row ) { return esc_html( (string) ( $row['aov_formatted'] ?? '' ) ); }, 'csv' => function ( $row ) { return (string) ( $row['aov'] ?? '' ); }, ), array( 'label' => $i18n['status'], 'render' => function ( $row ) { $slug = (string) ( $row['status_label'] ?? '' ); return $this->badge( $this->sold_status_color( $slug ), $this->sold_status_label( $slug ) ); }, 'csv' => function ( $row ) { return $this->sold_status_label( (string) ( $row['status_label'] ?? '' ) ); }, ), array( 'label' => $i18n['trend'], 'render' => function ( $row ) { return $this->trend_cell( $row ); }, 'csv' => function ( $row ) { return $this->trend_csv( $row ); }, ), ); case 'exceptions': return array( array( 'label' => $i18n['order_id'], 'render' => function ( $row ) { $id = absint( $row['order_id'] ?? 0 ); $link = (string) ( $row['edit_link'] ?? '' ); if ( $link ) { return sprintf( '#%d', esc_url( $link ), $id ); } return esc_html( (string) $id ); }, 'csv' => function ( $row ) { return (string) absint( $row['order_id'] ?? 0 ); }, ), $col_text( 'student', $i18n['student'] ), $col_text( 'course', $i18n['course'] ), $col_text( 'issue', $i18n['issue'] ), $col_text( 'date', $i18n['date'] ), array( 'label' => $i18n['severity'], 'render' => function ( $row ) { $slug = (string) ( $row['severity'] ?? '' ); return $this->badge( $this->severity_color( $slug ), $this->severity_label( $slug ) ); }, 'csv' => function ( $row ) { return $this->severity_label( (string) ( $row['severity'] ?? '' ) ); }, ), ); case 'top_students': $columns = array( $col_text( 'name', $i18n['student'] ), $col_text( 'enrolled', $i18n['enrolled'] ), $col_text( 'completed', $i18n['completed'] ), ); $has_scores = false; foreach ( $rows as $row ) { if ( null !== ( $row['avg_score'] ?? null ) ) { $has_scores = true; break; } } if ( $has_scores ) { $columns[] = array( 'label' => $i18n['avg_score'], 'render' => function ( $row ) { $val = $row['avg_score'] ?? null; return null === $val ? '—' : esc_html( $val . '%' ); }, 'csv' => function ( $row ) { $val = $row['avg_score'] ?? null; return null === $val ? '' : $val . '%'; }, ); } $columns[] = array( 'label' => $i18n['last_active'], 'render' => function ( $row ) { return $this->last_active_cell( $row['last_active'] ?? '' ); }, 'csv' => function ( $row ) { return (string) ( $row['last_active'] ?? '' ); }, ); $columns[] = array( 'label' => $i18n['status'], 'render' => function ( $row ) { $slug = (string) ( $row['status'] ?? '' ); return $this->badge( $this->student_status_color( $slug ), $this->student_status_label( $slug ) ); }, 'csv' => function ( $row ) { return $this->student_status_label( (string) ( $row['status'] ?? '' ) ); }, ); return $columns; case 'courses_by_students': return array( $col_text( 'name', $i18n['course'] ), $col_text( 'enrolled', $i18n['enrolled'] ), $col_text( 'started', $i18n['started'] ), $col_text( 'completed', $i18n['completed'] ), $col_completion( 'completion_rate', $i18n['completion'] ), $col_text( 'active_7d', $i18n['active_7d'] ), ); case 'instructor_performance': return array( $col_text( 'name', $i18n['instructor'] ), $col_text( 'courses', $i18n['courses_managed'] ), $col_text( 'students', $i18n['active_students'] ), $col_revenue( $i18n['total_revenue'] ), $col_completion( 'avg_completion', $i18n['avg_completion'] ), ); case 'instructor_report': return array( $col_text( 'name', $i18n['course'] ), $col_text( 'sold', $i18n['sold'] ), $col_revenue(), $col_text( 'enrolled', $i18n['enrolled'] ), ); default: return array(); } } /** * Assemble the TableListTemplate table + pagination footer. * * @param string $report * @param array $rows * @param array $columns * @param int $paged * @param int $per_page * @param int $total * @return string */ private function html_table( string $report, array $rows, array $columns, int $paged, int $per_page, int $total ): string { $header = array(); foreach ( $columns as $key => $col ) { $header[ $key ] = array( 'class' => $col['class'] ?? '', 'title' => $col['label'] ?? '', ); } $rows_html = ''; foreach ( $rows as $row ) { $tds = ''; foreach ( $columns as $col ) { $tds .= sprintf( '