Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5ad58bd
refactor: migrate learner management start date to progress repositories
donnapep Apr 1, 2026
6f16c89
fix: address review comments on learner management start date PR
donnapep Apr 1, 2026
dbf1ade
fix: add missing docblocks to progress model test methods
donnapep Apr 1, 2026
fc51a1f
fix: resolve Psalm type errors in learner management
donnapep Apr 1, 2026
0d5fda1
fix: deprecate sensei_learners_learner_updated filter with no replace…
donnapep Apr 15, 2026
ee4e923
Merge branch 'trunk' into add/hpps-learner-start-date-pr
donnapep Jun 18, 2026
c93921d
Rename changelog: avoid outdated 'learner management' term
donnapep Jun 19, 2026
761bc7b
Collapse duplicate progress repository get() call
donnapep Jun 19, 2026
c1d0987
Rename start date vars to student; drop redundant wp_timezone arg
donnapep Jun 19, 2026
b467a7e
Collapse duplicate progress repository save logic in edit_date_started
donnapep Jun 19, 2026
7a71c5d
Restore no-op guard when start date is unchanged
donnapep Jun 19, 2026
9edf462
Remove useless test cases
donnapep Jun 19, 2026
471eddf
Add edit_date_started tests; skip redundant save on no-op
donnapep Jun 19, 2026
90150b8
Merge branch 'trunk' into add/hpps-learner-start-date-pr
donnapep Jun 19, 2026
1938d21
Fix Psalm errors in edit_date_started
donnapep Jun 19, 2026
55a937a
Replace deprecated student-row filter with HPPS-safe sensei_students_…
donnapep Jun 19, 2026
ed41b7b
Address PR review: post_id sanitization, timezone-robust test, helper…
donnapep Jun 19, 2026
79e5018
Merge branch 'trunk' into add/hpps-learner-start-date-pr
donnapep Jul 8, 2026
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
4 changes: 4 additions & 0 deletions changelog/add-hpps-student-start-date
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Migrate student start date editing to use progress repositories instead of direct comment meta access.
93 changes: 64 additions & 29 deletions includes/admin/class-sensei-learner-management.php
Original file line number Diff line number Diff line change
Expand Up @@ -468,85 +468,120 @@ public function edit_date_started() {
check_ajax_referer( 'edit_date_nonce', 'security' );

if ( ! empty( $_POST['data']['post_id'] ) && is_numeric( $_POST['data']['post_id'] ) ) {
$post_id = (int) sanitize_key( $_POST['data']['post_id'] );
$post_id = absint( wp_unslash( $_POST['data']['post_id'] ) );
} else {
Comment thread
Copilot marked this conversation as resolved.
exit( '' );
exit;
Comment thread
donnapep marked this conversation as resolved.
}

$post = get_post( $post_id );

if ( empty( $post ) || ! is_a( $post, 'WP_Post' ) ) {
exit( '' );
exit;
}

if ( ! empty( $_POST['data']['comment_id'] ) && is_numeric( $_POST['data']['comment_id'] ) ) {
$comment_id = (int) sanitize_key( $_POST['data']['comment_id'] );
if ( ! empty( $_POST['data']['user_id'] ) && is_numeric( $_POST['data']['user_id'] ) ) {
$user_id = absint( wp_unslash( $_POST['data']['user_id'] ) );
} else {
exit( '' );
exit;
}

$comment = get_comment( $comment_id );
$post_type = get_post_type( $post_id );

if ( empty( $comment ) ) {
exit( '' );
if ( ! in_array( $post_type, array( 'course', 'lesson' ), true ) ) {
exit;
}

$post_type = get_post_type( $post_id );

if ( 'lesson' === $post_type ) {
$can_edit_date = $this->can_user_manage_students( (int) Sensei()->lesson->get_course_id( $post_id ), intval( $post->post_author ) );
} else {
$can_edit_date = $this->can_user_manage_students( $post_id, intval( $post->post_author ) );
}

if ( ! $can_edit_date ) {
exit( '' );
exit;
}

if ( ! empty( $_POST['data']['new_dates']['start-date'] ) ) {
$date_string = sanitize_text_field( wp_unslash( $_POST['data']['new_dates']['start-date'] ) );
} else {
exit( '' );
exit;
}

if ( empty( $date_string ) ) {
exit( '' );
exit;
}

$date_started = get_comment_meta( $comment_id, 'start', true );

$expected_date_format = 'Y-m-d H:i:s';
if ( false === strpos( $date_string, ' ' ) ) {
$expected_date_format = 'Y-m-d';
}

$date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string );
$date = DateTimeImmutable::createFromFormat( $expected_date_format, $date_string, wp_timezone() );
if ( false === $date ) {
exit( '' );
exit;
}

$utc_date = $date->setTimezone( new \DateTimeZone( 'UTC' ) );

$repository = 'course' === $post_type
? Sensei()->course_progress_repository_factory->create()
: Sensei()->lesson_progress_repository_factory->create();
Comment on lines +526 to +528

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A tiny possible improvement here could be to hide this logic behind a factory method like:

$repository = Sensei()->create_progress_repository( $post_type );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think I'm going to leave this for now for those 2 instances and re-assess if it grows.


$progress = $repository->get( $post_id, $user_id );
if ( ! $progress ) {
exit;
}

$old_started_at = $progress->get_started_at();
$started_at_changed = ( null === $old_started_at || $old_started_at->getTimestamp() !== $utc_date->getTimestamp() );

if ( $started_at_changed ) {
$progress->set_started_at( $utc_date );

/**
* The repository and progress are created from the same factory, so their types
* always match, but Psalm cannot correlate the two union types.
*
* @psalm-suppress PossiblyInvalidArgument
*/
$repository->save( $progress );
}

$formatted_date = gmdate( 'Y-m-d H:i:s', $date->getTimestamp() );
$updated = (bool) update_comment_meta( $comment_id, 'start', $formatted_date, $date_started );
/**
* Filters whether a student row was updated on the Students screen.
*
* Lets extensions persist additional date changes (e.g. course access dates) when a
* student's dates are edited and report whether a change occurred so the row refreshes.
*
* @since $$next-version$$
*
* @hook sensei_students_row_updated
*
* @param {bool} $started_at_changed Whether the start date was changed.
* @param {int} $post_id Course or lesson ID.
* @param {int} $user_id Student user ID.
* @param {string} $post_type Post type ('course' or 'lesson').
* @return {bool} Whether the student row was updated.
*/
$updated = (bool) apply_filters( 'sensei_students_row_updated', $started_at_changed, $post_id, $user_id, $post_type );

/**
* Filter sensei_learners_learner_updated
*
* This filter should return false if there was no update in the learner row.
* @deprecated $$next-version$$ Use sensei_students_row_updated instead.
*
* @hook sensei_learners_learner_updated
*
* @param {bool} $updated A flag indicating if there was an update in the learner row.
* @param {bool} $updated A flag indicating if there was an update in the student row.
* @param {int} $post_id Lesson or course id.
* @param {int} $comment_id The comment id which tracks the progress of the learner.
* @param {int} $comment_id The comment id which tracks the progress of the student.
* @return {bool} False if there were no updates.
*/
$updated = apply_filters( 'sensei_learners_learner_updated', $updated, $post_id, $comment_id );
$updated = (bool) apply_filters_deprecated( 'sensei_learners_learner_updated', array( $updated, $post_id, $progress->get_id() ), '$$next-version$$', 'sensei_students_row_updated' );

if ( false === $updated ) {
exit( '' );
if ( ! $updated ) {
wp_die();
}

exit( esc_html( $formatted_date ) );
wp_die( esc_html( $date->format( 'Y-m-d H:i:s' ) ) );
}

/**
Expand Down
12 changes: 10 additions & 2 deletions includes/admin/class-sensei-learners-main.php
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,16 @@ protected function get_row_data( $item ) {
$actions[] = $edit_start_date_form;
}

$date_started = get_comment_meta( $user_activity->comment_ID, 'start', true );
$date_input = '<input class="edit-date-date-picker" data-name="start-date" type="text" value="' . esc_attr( $date_started ) . '">';
$student_progress = null;
if ( $post_id ) {
$progress_repository = 'course' === $post_type
? Sensei()->course_progress_repository_factory->create()
: Sensei()->lesson_progress_repository_factory->create();
Comment on lines +541 to +543

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No surprise, that factory method could be used here as well.
Perhaps, there are (or will be) even more places for it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$student_progress = $progress_repository->get( (int) $post_id, $user_activity->user_id );
}
$student_started_at = $student_progress ? $student_progress->get_started_at() : null;
$date_started = $student_started_at ? (string) wp_date( 'Y-m-d H:i:s', $student_started_at->getTimestamp() ) : '';
$date_input = '<input class="edit-date-date-picker" data-name="start-date" type="text" value="' . esc_attr( $date_started ) . '">';

/**
* Filter sensei_learners_main_column_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@ public function get_created_at(): DateTimeInterface {
return $this->created_at;
}

/**
* Set the course start date.
*
* @internal
*
* @since $$next-version$$
*
* @param DateTimeInterface $started_at Course start date.
*/
public function set_started_at( DateTimeInterface $started_at ): void {
$this->started_at = $started_at;
}

/**
* Set the course progress updated date.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ public function get_updated_at(): DateTimeInterface;
*/
public function get_created_at(): DateTimeInterface;

/**
* Set the course start date.
*
* @internal
*
* @since $$next-version$$
*
* @param DateTimeInterface $started_at Course start date.
*/
public function set_started_at( DateTimeInterface $started_at ): void;

/**
* Set the course progress updated date.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,19 @@ public function get_updated_at(): DateTimeInterface {
return $this->updated_at;
}

/**
* Set the lesson start date.
*
* @internal
*
* @since $$next-version$$
*
* @param DateTimeInterface $started_at The start date.
*/
public function set_started_at( DateTimeInterface $started_at ): void {
$this->started_at = $started_at;
}

/**
* Set lesson progress updated date.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ public function get_created_at(): DateTimeInterface;
*/
public function get_updated_at(): DateTimeInterface;

/**
* Set the lesson start date.
*
* @internal
*
* @since $$next-version$$
*
* @param DateTimeInterface $started_at The start date.
*/
public function set_started_at( DateTimeInterface $started_at ): void;

/**
* Set lesson progress updated date.
*
Expand Down
83 changes: 83 additions & 0 deletions tests/unit-tests/admin/test-class-sensei-learner-management.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,87 @@ public function testAddNewLearners_ToLessonWhenCurrentUserIsNotTeacher_ReturnsFa
/* Assert. */
$this->assertFalse( $result );
}

/**
* Tests that a valid start date edit is saved to the progress repository.
*
* @covers Sensei_Learner_Management::edit_date_started
*/
public function testEditDateStarted_ValidCourseDateGiven_SavesStartedAtToRepository() {
/* Arrange. */
wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
$student_id = $this->factory->user->create();
$course_id = $this->factory->course->create();
Sensei_Utils::user_start_course( $student_id, $course_id );

$start_date = '2024-01-15 10:30:00';

/* Act. */
$response = $this->invoke_edit_date_started(
array(
'post_id' => $course_id,
'user_id' => $student_id,
'new_dates' => array( 'start-date' => $start_date ),
)
);

/* Assert. */
$progress = Sensei()->course_progress_repository_factory->create()->get( $course_id, $student_id );
$saved_date = wp_date( 'Y-m-d H:i:s', $progress->get_started_at()->getTimestamp(), wp_timezone() );
$this->assertSame( $start_date, $response, 'Response should be the formatted start date.' );
$this->assertSame( $start_date, $saved_date, 'Start date should be saved to the repository.' );
}

/**
* Tests that submitting the same start date returns an empty response and does not report an update.
*
* @covers Sensei_Learner_Management::edit_date_started
*/
public function testEditDateStarted_UnchangedDateGiven_ReturnsEmptyResponse() {
/* Arrange. */
wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
$student_id = $this->factory->user->create();
$course_id = $this->factory->course->create();
Sensei_Utils::user_start_course( $student_id, $course_id );

$data = array(
'post_id' => $course_id,
'user_id' => $student_id,
'new_dates' => array( 'start-date' => '2024-01-15 10:30:00' ),
);
$this->invoke_edit_date_started( $data );

/* Act. */
$response = $this->invoke_edit_date_started( $data );

/* Assert. */
$this->assertSame( '', $response, 'Resubmitting the same date should return an empty response.' );
}

/**
* Invokes the edit_date_started AJAX handler and returns the response body.
*
* The success and no-op paths exercised by these tests terminate via wp_die(), which the
* test framework throws as WPDieException; this captures the emitted body and fails the
* test if the handler returns without dying. (Validation-failure paths use exit and are
* not exercised here.)
*
* @param array $data The POST data payload.
*
* @return string The response body emitted by the handler.
*/
private function invoke_edit_date_started( array $data ): string {
$nonce = wp_create_nonce( 'edit_date_nonce' );
$_REQUEST['security'] = $nonce;
$_POST['security'] = $nonce;
$_POST['data'] = $data;

try {
$this->learner_management->edit_date_started();
} catch ( \WPDieException $e ) {
return $e->getMessage();
}

$this->fail( 'Expected edit_date_started() to call wp_die().' );
}
}
Loading