Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Linting
- **PHPCS**: Run `npm run lint-php`.
- **Psalm**: Run `vendor/bin/psalm --no-cache --diff`.
- **Before pushing**: The pre-commit hook only lints new files. CI lints all changed lines. Always run both PHPCS and Psalm on modified files before pushing to avoid CI failures.

## Conventions
- **Changelogs**: Every user-facing change MUST have a changelog entry before opening a PR. Run `npm run changelog` (entries stored in `changelog/`).
4 changes: 4 additions & 0 deletions changelog/add-hpps-reports-grading-migration
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Internal: Integrate HPPS in Lessons report.
4 changes: 4 additions & 0 deletions changelog/fix-course-last-activity-date
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix Course Reports Last Activity showing an arbitrary date instead of the most recent activity date across all lessons.
4 changes: 4 additions & 0 deletions changelog/fix-days-to-complete-ungraded-failed
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix Days to Completion calculation in Reports to exclude lessons with ungraded or failed quizzes, which do not have a valid completion date.
25 changes: 24 additions & 1 deletion config/psalm/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,14 @@
</file>
<file src="includes/class-sensei-grading-main.php">
<ArgumentTypeCoercion occurrences="1"/>
<InvalidArrayAccess occurrences="6">
<code>$counts['complete']</code>
<code>$counts['failed']</code>
<code>$counts['graded']</code>
<code>$counts['in-progress']</code>
<code>$counts['passed']</code>
<code>$counts['ungraded']</code>
</InvalidArrayAccess>
<MissingParamType occurrences="1">
<code>$args</code>
</MissingParamType>
Expand Down Expand Up @@ -4549,11 +4557,25 @@
</PossiblyInvalidPropertyFetch>
</file>
<file src="includes/internal/services/class-comments-based-progress-aggregation-service.php">
<PossiblyInvalidPropertyFetch occurrences="5">
<code>$row-&gt;days_to_complete_count</code>
<code>$row-&gt;days_to_complete_sum</code>
<code>$row-&gt;lesson_completed_count</code>
<code>$row-&gt;lesson_start_count</code>
<code>$row-&gt;unique_student_count</code>
</PossiblyInvalidPropertyFetch>
<UndefinedConstant occurrences="1">
<code>ARRAY_A</code>
</UndefinedConstant>
</file>
<file src="includes/internal/services/class-tables-based-progress-aggregation-service.php">
<PossiblyInvalidPropertyFetch occurrences="5">
<code>$row-&gt;days_to_complete_count</code>
<code>$row-&gt;days_to_complete_sum</code>
<code>$row-&gt;lesson_completed_count</code>
<code>$row-&gt;lesson_start_count</code>
<code>$row-&gt;unique_student_count</code>
</PossiblyInvalidPropertyFetch>
<UndefinedConstant occurrences="2">
<code>ARRAY_A</code>
</UndefinedConstant>
Expand Down Expand Up @@ -4827,7 +4849,8 @@
<InvalidArgument occurrences="1">
<code>$lessons</code>
</InvalidArgument>
<InvalidScalarArgument occurrences="1">
<InvalidScalarArgument occurrences="2">
<code>$lesson_students</code>
<code>$value</code>
</InvalidScalarArgument>
<PossibleRawObjectIteration occurrences="1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public function __construct( \wpdb $wpdb ) {
*/
public function count_statuses( array $args ): array {
if ( empty( $args['type'] ) || ! in_array( $args['type'], array( 'course', 'lesson' ), true ) ) {
_doing_it_wrong(
__METHOD__,
'The "type" argument must be "course" or "lesson".',
'$$next-version$$'
);
return array();
}

Expand All @@ -73,6 +78,7 @@ public function count_statuses( array $args ): array {
$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
$query .= $this->build_user_exclusion_clause( $args );
$query .= $this->build_unsubmitted_quiz_exclusion_clause( $args );

if ( isset( $args['query'] ) ) {
$query .= $args['query'];
Expand All @@ -91,6 +97,62 @@ public function count_statuses( array $args ): array {
return $counts;
}

/**
* Get aggregate totals for a set of lessons.
*
* @since $$next-version$$
*
* @param int[] $lesson_ids Array of lesson post IDs.
* @return array Associative array with keys: unique_student_count, lesson_start_count, lesson_completed_count, days_to_complete_count, days_to_complete_sum.
*/
public function get_lesson_totals( array $lesson_ids ): array {
$defaults = [
'unique_student_count' => 0,
'lesson_start_count' => 0,
'lesson_completed_count' => 0,
'days_to_complete_count' => 0,
'days_to_complete_sum' => 0,
];

if ( empty( $lesson_ids ) ) {
return $defaults;
}

$wpdb = $this->wpdb;
$placeholders = implode( ', ', array_fill( 0, count( $lesson_ids ), '%d' ) );
$completed = "'" . implode( "','", Grading_Item::COMPLETED_STATUSES ) . "'";
$has_completion = "'" . implode( "','", Grading_Item::STATUSES_WITH_COMPLETION_DATE ) . "'";

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table names from wpdb. Placeholders created dynamically. Date format string uses literal %s for MySQL STR_TO_DATE.
$query = $wpdb->prepare(
"SELECT COUNT(DISTINCT(lesson_students.user_id)) unique_student_count
, COUNT(lesson_students.comment_id) lesson_start_count
, SUM(IF(lesson_students.comment_approved IN ($completed), 1, 0)) lesson_completed_count
, SUM(IF(lesson_students.comment_approved IN ($has_completion), 1, 0)) days_to_complete_count
, SUM(IF(lesson_students.comment_approved IN ($has_completion), ABS( DATEDIFF( STR_TO_DATE( lesson_start.meta_value, %s ), lesson_students.comment_date ) ) + 1, 0)) days_to_complete_sum
FROM {$wpdb->comments} lesson_students
LEFT JOIN {$wpdb->commentmeta} lesson_start ON lesson_start.comment_id = lesson_students.comment_id
WHERE lesson_start.meta_key = 'start' AND lesson_students.comment_post_id IN ( $placeholders )",
array_merge( [ '%Y-%m-%d %H:%i:%s' ], $lesson_ids )
);
// phpcs:enable

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$row = $wpdb->get_row( $query );

if ( ! $row ) {
return $defaults;
}

return [
'unique_student_count' => (int) $row->unique_student_count,
'lesson_start_count' => (int) $row->lesson_start_count,
'lesson_completed_count' => (int) $row->lesson_completed_count,
'days_to_complete_count' => (int) $row->days_to_complete_count,
'days_to_complete_sum' => (int) $row->days_to_complete_sum,
];
}

/**
* Build SQL clause for filtering by post ID(s).
*
Expand All @@ -102,16 +164,18 @@ public function count_statuses( array $args ): array {
private function build_post_filter_clause( array $args ): string {
$wpdb = $this->wpdb;

// Prefer post_id (single lesson filter) over post__in (course lessons)
// so that counts reflect the specific lesson when both are set.
if ( ! empty( $args['post_id'] ) ) {
return $wpdb->prepare( ' AND comment_post_ID = %d', $args['post_id'] );
}

if ( ! empty( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
$placeholders = implode( ', ', array_fill( 0, count( $args['post__in'] ), '%d' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholders created dynamically.
return $wpdb->prepare( " AND comment_post_ID IN ( $placeholders )", $args['post__in'] );
}

if ( ! empty( $args['post_id'] ) ) {
return $wpdb->prepare( ' AND comment_post_ID = %d', $args['post_id'] );
}

return '';
}

Expand Down Expand Up @@ -175,4 +239,36 @@ private function build_user_exclusion_clause( array $args ): string {

return " AND $exclusion_sql";
}

/**
* Build SQL clause for excluding completed lessons with no quiz submission.
*
* When enabled, excludes lessons where a quiz exists but the student has
* no quiz answers — there is nothing to grade. This covers both 'complete'
* (never submitted) and orphaned 'passed'/'graded'/'failed' records with
* no answer data.
*
* @since $$next-version$$
*
* @param array $args Query arguments.
* @return string SQL clause.
*/
private function build_unsubmitted_quiz_exclusion_clause( array $args ): string {
$exclude = $args['exclude_unsubmitted_quiz_completions'] ?? false;

if ( ! $exclude ) {
return '';
}

$wpdb = $this->wpdb;

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb.
return " AND NOT ( comment_approved != 'in-progress'"
. " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm"
. " WHERE pm.post_id = {$wpdb->comments}.comment_post_ID"
. " AND pm.meta_key = '_lesson_quiz' AND pm.meta_value > 0 )"
. " AND NOT EXISTS ( SELECT 1 FROM {$wpdb->commentmeta} cm"
. " WHERE cm.comment_id = {$wpdb->comments}.comment_ID"
. " AND cm.meta_key = 'quiz_answers' ) )";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,49 @@ public function filter_courses_by_last_activity( array $clauses, string $from =

return $clauses;
}

/**
* Modify WP_Query clauses to add last activity date to lesson posts.
*
* @since $$next-version$$
*
* @param array $clauses Associative array of the clauses for the query.
* @return array Modified associative array of the clauses for the query.
*/
public function add_last_activity_to_lessons_clauses( array $clauses ): array {
$wpdb = $this->wpdb;

$clauses['fields'] .= ", (
SELECT MAX({$wpdb->comments}.comment_date_gmt)
FROM {$wpdb->comments}
WHERE {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID
AND {$wpdb->comments}.comment_approved IN ('complete', 'passed', 'graded')
AND {$wpdb->comments}.comment_type = 'sensei_lesson_status'
) AS last_activity_date";

return $clauses;
}

/**
* Modify WP_Query clauses to add days-to-complete data to lesson posts.
*
* @since $$next-version$$
*
* @param array $clauses Associative array of the clauses for the query.
* @return array Modified associative array of the clauses for the query.
*/
public function add_days_to_completion_to_lessons_clauses( array $clauses ): array {
$wpdb = $this->wpdb;
$has_completion = "'" . implode( "','", Grading_Item::STATUSES_WITH_COMPLETION_DATE ) . "'";

$clauses['fields'] .= ", (SELECT SUM( ABS( DATEDIFF( STR_TO_DATE( {$wpdb->commentmeta}.meta_value, '%Y-%m-%d %H:%i:%s' ), {$wpdb->comments}.comment_date )) + 1 ) as days_to_complete";
$clauses['fields'] .= " FROM {$wpdb->comments}";
$clauses['fields'] .= " INNER JOIN {$wpdb->commentmeta} ON {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id";
$clauses['fields'] .= " WHERE {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID";
$clauses['fields'] .= " AND {$wpdb->comments}.comment_type IN ('sensei_lesson_status')";
$clauses['fields'] .= " AND {$wpdb->comments}.comment_approved IN ( $has_completion )";
$clauses['fields'] .= " AND {$wpdb->commentmeta}.meta_key = 'start') as days_to_complete";

return $clauses;
}
}
Loading
Loading